diff --git a/README.md b/README.md index 9bbb466..d92d89a 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ 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. +Okna budowania (`b`) i rekrutacji (`r`) mają zakładki przełączane strzałkami ←/→. Zaznaczona pozycja jest wyróżniona jasną magentą. Obecnie dostępne są Mine i Support w budowaniu oraz Miner w rekrutacji; strzałki w tych oknach nie przesuwają kursora mapy. Enter wykonuje dostępną akcję, a ponowne b/r zamyka odpowiednie okno. Wood i Metal mają rzeczywisty stan w magazynie każdego gracza, inicjalizowany z `--starting-wood` i `--starting-metal`. Nagłówek pokazuje bieżące zasoby. Gracz otrzymuje 5 Wood co około 5 sekund symulacji; metal nie przyrasta bez kopalni. Złoża Ore są początkowo neutralne. @@ -117,6 +117,22 @@ 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. +## Stres tuneli i podpory + +Stres rośnie o 20 punktów procentowych na każdą kratkę najkrótszej połączonej trasy od bazy: 20%, 40%, 60%, 80%. Liczą się połączenia góra/dół/lewo/prawo, nie odległość w linii prostej. Podpora ustala stres swojego tunelu na 20% i stabilizuje sąsiednią sieć we wszystkich czterech kierunkach. Gotowe kopalnie przepuszczają połączenie; bazy są punktami odniesienia dla obu stron. + +- `b` → strzałkami wybierz **Support** → `Enter`: rozpoczyna około 3-sekundową budowę podpory w tunelu za **10 Wood + 2 Metal**. Własny Miner musi stać na tej kratce lub obok niej. +- `Delete` w zakładce Support rozpoczyna około 3-sekundowe usuwanie podpory bez zwrotu zasobów. Do końca pracy podpora nadal stabilizuje tunel; dopiero potem są przeliczane stres i zawalenia. +- Przy **100%** tunel zawala się, a odcięte, niestabilne odcinki również ulegają zawaleniu. Wszystkie zasypane jednostki giną wraz ze swoimi kolejkami. +- Zawalony tunel (`#`) ma **750 durability**, jest nieprzechodni i można go ponownie wykopać. Zwykły kamień ma 1000, a tunel 500 durability. +- `s` przełącza widok stresu: ukrywa inne oznaczenia kratek, pokazuje procenty i kolory od zielonego do czerwonego; kursor zachowuje dotychczasowe wyróżnienie. W najmniejszym zoomie gotowe 100% jest pokazane jako `100`. + +Najpierw zaznacz własnego Minera przez `Space`. Dopiero wtedy widoczny i dostępny jest `t`, który otwiera **boczny panel wyboru zadania**, obok planszy. Strzałkami ←/→ wybierz **Digging** albo **Moving**; `Enter` zatwierdza wybór i wraca do wytyczania trasy, `t` zamyka panel. Digging przechodzi po tunelach i kopie przeszkody; Moving pozwala wytyczać trasę tylko po polach przechodnich i czeka, jeśli droga później się zawali. `a` przełącza automatyczne podpory dla Digging (domyślnie włączone); działa też podczas wytyczania takiej trasy. Ustawienie jest zapisywane w nowych zadaniach, nie zmienia już zakolejkowanych. Spacja pozostaje skrótem do zwykłego wytyczania trasy z kopaniem. + +Automat stawia podporę na ostatniej bezpiecznej kratce 80% dopiero, gdy kolejny wykop wymaga zabezpieczenia. Na prostej trasie pierwsza podpora wypada cztery kratki od bazy, następne co trzy kratki; niepotrzebna podpora na końcu trasy nie powstaje. Każda kosztuje rzeczywiste zasoby właściciela. **Brak środków lub wyłączenie automatu może doprowadzić do śmierci górnika przy kolejnym wykopie.** Wykonane tunele i uszkodzenia pozostają po anulowaniu zadania. + +Budowniczy na czas pracy nad podporą wstrzymuje swój ruch i kopanie. Postęp jest widoczny na kratce i w panelach; bez dostępnego budowniczego praca pauzuje. Automat rozpoczyna budowę przed niebezpiecznym wykopem i czeka na ukończenie podpory. W mapie stresu zwykły kamień zachowuje normalny wygląd i nie pokazuje procentu stresu. + ## Odwoływanie zadań jednostki Pozostałe ścieżki zadań własnych jednostek są podświetlane kolorem LightMagenta, a pole ścieżki pod kursorem — Magenta. Podświetlenie obejmuje wszystkie zadania w kolejce i znika wraz z wykonaniem lub odwołaniem odcinków. diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 9bac07f..44512f5 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -11,6 +11,8 @@ Instrukcje i notatki dla agentów przechowujemy w `docs/`. Ten plik jest punktem ## Narzędzia i weryfikacja +Flake uruchamiany z repozytorium Git pomija pliki nieśledzone, nawet gdy Cargo widzi je lokalnie. Przy błędach Nix „file not found for module” sprawdź git status i git ls-files. Nowe źródła i testy można uwidocznić przez git add -N -- (intent-to-add, bez dodawania treści do stagingu ani tworzenia commita). Sama zmiana src = ./. w default.nix nie przywróci plików pominiętych na wejściu flake. + Nie wolno zakończyć zadania, jeżeli kompilator Rust zgłasza błędy uniemożliwiające kompilację. Przed zakończeniem uruchom `nix develop -c cargo check --all-targets`; jeśli wystąpią błędy kompilacji, usuń je i powtórz kontrolę aż do uzyskania sukcesu. Wszystkie narzędzia Rust uruchamiaj przez `nix develop -c` z katalogu repozytorium: @@ -25,7 +27,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 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. +Aktualny zestaw obejmuje 106 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ń. @@ -35,6 +37,8 @@ Audio wymaga ALSA i urządzenia odtwarzania; do pracy nad TUI używaj `--disable ## Konwencje kodu +Mechanikę tuneli sprawdzaj przez tests/tunnel_stress.rs. Pełna symulacja przechodzi przez SkirmishState::tick_update, który rozlicza Excavation, środki na automatyczne podpory i śmiertelne kaskady. Ręczne podpory zmieniaj przez build_support/remove_support; surowa flaga Tunnel::set_support jest API modelu i sama nie rozlicza zasobów ani zawaleń. Digging/Moving muszą zachować auto_supports przy konwersji; jawnie wybrany Moving (dig_if_blocked=false) nie może zacząć kopać. Render stresu nie może nadpisywać modelu komórki. + - Formatowanie określa `rustfmt.toml`: styl Rust 2024, wcięcia po 2 spacje, szerokość docelowa 100 znaków i zakończenia LF. Stosuj `nix develop -c cargo fmt --all`; kontroluj przez `nix develop -c cargo fmt --all -- --check`. - Jawne adnotacje typów są OBOWIĄZKOWE w nowym i zmienianym kodzie: przy zmiennych i stałych, polach struktur, parametrach funkcji i domknięć oraz typach zwracanych (także `-> ()` dla funkcji bez wyniku). Nie polegaj na inferencji typu tam, gdzie można podać typ jawnie. Dotyczy to również testów. Jest to zasada jawnego typowania; rzutowania wartości (`as`, `From`, `TryFrom`) stosuj tylko wtedy, gdy potrzebna jest rzeczywista konwersja. - Rust 2024, jedna biblioteka i jeden binarny punkt wejścia; bez workspace. @@ -45,4 +49,4 @@ Audio wymaga ALSA i urządzenia odtwarzania; do pracy nad TUI używaj `--disable - 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`. +- Tick potyczki najpierw rozlicza 3-sekundowe prace podpór i uruchamia automat przed niebezpiecznym wykopem, następnie używa advance_turn_with_events i rozstrzyga zawalenia; advance_turn to sam krok jednostek. Zachowaj jedną aktualizację na jednostkę na tick, aktualność jej współrzędnych, kolejność zadań i ochronę zajętych pól. Regresje sprawdzaj przez `tests/digging.rs`. diff --git a/docs/CODE_MAP.md b/docs/CODE_MAP.md index 01d6728..b8c04eb 100644 --- a/docs/CODE_MAP.md +++ b/docs/CODE_MAP.md @@ -56,7 +56,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` wykonuje `board.advance_turn()` i zwiększa `turn_counter`. +Kanały to standardowe `std::sync::mpsc`. Stan gry mutuje główny wątek. Każdy obrót pętli najpierw rysuje, następnie próbuje odebrać po jednym zdarzeniu z każdego kanału. Aktualizacja potyczki odbywa się niezależnie od wybranego ekranu. `tick_update` wykonuje kroki jednostek przez advance_turn_with_events, rozlicza podpory i zawalenia, rekrutację, budowę oraz zasoby, a następnie zwiększa licznik czasu. `GameStates::new` tworzy wszystkie stany jednocześnie. `SettingsState` przechowuje parametry z CLI; `SkirmishState` ma odrębne bieżące `player_resources` i `enemy_resources`, inicjalizowane z starting_wood/starting_metal. Nagłówek TUI odczytuje te magazyny zamiast ustawień. `--starting-iron` jest aliasem nowego `--starting-metal`; XP, supply i tryb gry nadal nie mają pełnej symulacji. `View` zawiera MainMenu, Skirmish, PerkDecks i SkillsConfig. Dwa ostatnie używają `default_view` z napisem „Work in progress”; ustawienia nie mają osobnego wariantu `View`. @@ -79,7 +79,7 @@ flowchart LR `BoardState.cells: Vec>` łą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 neutralne złoża i po jednym górniku w bazie. Kopalnie nie są rozmieszczane na starcie. CLI domyślnie wybiera mapę 42 × 11; dopuszczalne zakresy to szerokość 36–108 i wysokość 11–39. Struktury to Base, Ore, Stone, Tunnel, MineConstruction i Mine; jedyna jednostka to Miner. Zadania to Digging i Moving, implementujące wspólny trait Task. +Mapa powstaje deterministycznie: kamień, baza gracza w `(map_height / 2, 2)`, baza przeciwnika w `(map_height / 2, map_width - 3)`, sąsiadujące neutralne złoża i po jednym górniku w bazie. Kopalnie nie są rozmieszczane na starcie. CLI domyślnie wybiera mapę 42 × 11; dopuszczalne zakresy to szerokość 36–108 i wysokość 11–39. Struktury to Base, Ore, Stone, Tunnel, MineConstruction, Mine i CollapsedTunnel; jedyna jednostka to Miner. Zadania to Digging i Moving, implementujące wspólny trait Task. `Resources` przechowuje Wood i Metal jako u32; dodawanie saturuje zamiast przepełniać licznik. `tick_update` po zadaniach jednostek dodaje graczowi 5 Wood co 152 ticki (5016 ms). Co 31 ticków (1023 ms) kopalnie wydobywają po 1 Metal lub 2 Metal, jeśli na ich kratce stoi własny Miner. `Mine::accepts_unit` określa możliwość obsadzenia, a `production_per_cycle` ogranicza wydobycie pozostałą zawartością złoża. Rzeczywiście uzyskana ilość jest odejmowana z Ore i dopisywana właścicielowi. Wyczerpane złoże pozostaje z ilością zero. SidePanelWidget pokazuje tempo i aktywny bonus Miner. Wartości i okresy określają stałe w resources.rs oraz Mine::METAL_PER_CYCLE/STAFFED_METAL_PER_CYCLE. @@ -97,7 +97,7 @@ r jest dostępne na własnej Base poza rysowaniem trasy i widokiem budowania. Sk ## 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. +Okna budowania i rekrutacji mają Tabs oraz osobne indeksy selected_building/selected_recruit w SkirmishState. Listy BUILDING_OPTIONS/RECRUITMENT_OPTIONS zawierają obecnie Mine i Support / Miner. cycle_popup_option zawija wybór strzałkami Left/Right w obrębie aktywnego okna, bez ruchu planszy. Przy dodawaniu kolejnego typu trzeba uzupełnić listę oraz powiązać szczegóły, walidację i akcje Enter/Delete z jego indeksem; obecna implementacja akcji obsługuje tylko istniejące typy. CellWidget pokazuje cyjanowy postęp BaseBuilding::recruitment_progress na środku kratki, gdy queued_miners > 0. Wskaźnik obejmuje 0% i oczekiwanie przy 100%; po opróżnieniu kolejki znika. W najmniejszym zoomie 100 nie ma sufiksu %, aby zmieścić trzy znaki. Rekrutacja ma pierwszeństwo przed procentem ruchu jednostki opuszczającej bazę; w zwykłym i dużym zoomie zachowany jest znacznik jednostki. @@ -125,6 +125,18 @@ Gdy panel Tab jest otwarty, fokus wskazuje własną jednostkę z zadaniami i nie 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. +## Stres, podpory i zawalenia + +Panel zadań ma zakładki TASK_OPTIONS: Digging/Moving i indeks selected_task. Digging zachowuje dotychczasowy podział trasy na odcinki kopania i ruchu. Wybór Moving ustawia BoardState::movement_only na czas rysowania: kursor nie przekracza nieprzechodnich pól, a zadania MovingTask otrzymują dig_if_blocked=false, więc nie zamieniają się w kopanie po późniejszym zawaleniu drogi. Domyślne zadania ruchu w ramach trasy kopania zachowują konwersję. Zatwierdzenie/anulowanie czyści movement_only; spacja rozpoczynająca nową trasę wybiera domyślne kopanie. Zamknięcie panelu t i przejście Enter do rysowania odtwarzają rozmiar planszy zależny od Tab. + +`Tunnel` przechowuje roof_support i stres. Podpora daje `get_level() == 'T'`, kosztuje Tunnel::SUPPORT_COST (10 Wood, 2 Metal). `SkirmishState::can_build_support/build_support/remove_support` sprawdzają teren, własnego budowniczego na polu lub obok, zasoby i aktualny stan podpory. Budowa i usuwanie trwają po 91 ticków pracy (3003 ms). Tunnel przechowuje SupportWork (owner, removing, ticks); dopiero ukończenie zmienia roof_support. Usunięcie nie zwraca zasobów i wywołuje kaskadę po zakończeniu. Nie istnieje osobny budynek podpory. + +`board_stress.rs` rozszerza BoardState: recalculate_stress wykonuje wieloźródłowe przejście po siatce. Bazy startują z odległością 0, podpory z 20; każdy krok po tunelu lub gotowej Mine dodaje 20, z limitem 100. Odłączone tunele mają 100. resolve_collapses zamienia tunele o stresie >=100 w CollapsedTunnel, usuwa jednostki i ponawia przeliczenie do stabilizacji. Jeśli zginął zaznaczony budowniczy, czyści rysowanie jego trasy. CollapsedTunnel ma 750 HP, tag #, stres 100 i nie jest przechodni; można go wykopać ponownie. Obrażenia kopania odnoszą się do bazowych 1000 HP kamienia, więc gruz usuwa się szybciej. + +`advance_turn_with_events` wykonuje jeden krok jednostek i zwraca Excavation (origin, destination, owner, auto_supports). Tick SkirmishState najpierw advance_support_work (maksymalnie jedna praca na budowniczego, pauza bez budowniczego), następnie prepare_automatic_supports. Przed niebezpiecznym wykopem automat opłaca i rozpoczyna podporę na własnym polu 80%; prospective_tunnel_stress wylicza ryzyko przed uszkodzeniem przeszkody. working_on_support wstrzymuje ruch i kopanie pracującego górnika. Po wykopach tick rozstrzyga zawalenia przed rekrutacją i budową. Automat nie stawia podpory na końcu trasy, jeśli kolejny wykop jej nie potrzebuje. Low-level advance_turn pozostaje krokiem jednostek bez gospodarki i rozstrzygania stresu; pełną symulację uruchamia tick_update. Zakończenie budowy Mine również odświeża odległości sieci. + +`Task::auto_supports/set_auto_supports` delegują ustawienie przez Tasks, DiggingTask i MovingTask; konwersja rodzaju zadania je zachowuje. BoardState::auto_supports jest domyślnym wyborem kopiowanym do nowych segmentów podczas zatwierdzenia trasy. Panel `t` jest dostępny tylko w trybie zaznaczenia przez Space i korzysta z prawego obszaru istniejącego layoutu, bez okna na środku mapy; `a` zmienia opcję, Enter zmienia rodzaj aktualnej trasy przez configure_selected_task, bez zatwierdzania jej do kolejki. Jeśli wybrano Moving, a szkic zawiera kamień, resetuje szkic do początku. Resize uwzględnia task_view. Budowanie ma zakładki Mine/Support. `s` przełącza stress_view i BoardWidget rysuje komórki przez CellWidget::render_stress: procent, gradient zielony/czerwony i dotychczasowy kolor fokusu, bez znaczników jednostek, tras i innych postępów; zwykły Stone jest wyjątkiem i zachowuje normalny wygląd, bez stresu. Model komórek nie jest mutowany przez renderowanie. + ## Audio i pakowanie Główny wątek wysyła `AudioCmd`; osobny wątek obsługuje głośność, mute, część i soundtrack. Cache wykorzystuje `Lazy`, `RwLock`, `Arc` i bajty OGG. Calm ładuje `calm.ogg`; Buildup, Assault i Outro korzystają obecnie z `test.ogg`. Błędy urządzenia, odczytu i dekodowania mogą zakończyć wątek przez `expect`. @@ -141,7 +153,7 @@ To obserwacje statyczne, nie lista napraw wykonanych w tej sesji: - **Zaznaczenie:** wybrany górnik czeka podczas rysowania trasy; inne jednostki wykonują zadania normalnie. - **Audio:** usunięto aktywne odpytywanie; pomiar CPU na docelowym urządzeniu i odsłuch pozostają do wykonania. Test dekodera nie weryfikuje urządzenia audio. - **Granice UI:** małe terminale, resize i trzy poziomy zoomu wymagają interaktywnego sprawdzenia. Indeksowanie planszy zakłada poprawne współrzędne. -- **Pokrycie:** 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. +- **Pokrycie:** 106 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ę diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5313d09..e1dc875 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,5 +1,29 @@ # Dziennik prac +## 2026-10-24 — czas podpór, kamień bez stresu i wybór zadania po Space + +Budowa i usuwanie podpór trwają po 91 ticków pracy (3003 ms). Tunnel przechowuje SupportWork z właścicielem, rodzajem pracy i postępem; roof_support zmienia się dopiero przy ukończeniu. Budowa pobiera koszt raz przy rozpoczęciu, a podpora w trakcie rozbiórki nadal stabilizuje sieć. Kaskada następuje dopiero po zakończeniu usuwania. Postęp jest na kratce, w zakładce Support i pod Tab. Bez budowniczego timer pauzuje; jeden górnik nie wykonuje dwóch podpór naraz, a jego ruch i kopanie są wstrzymane na czas pracy. + +Automatyczne podpory są teraz przygotowywane przed ruchem i kopaniem: prospective_tunnel_stress przewiduje stres przyszłego wykopu bez zmiany terenu. Przy ryzyku 100% automat rozpoczyna pracę na obecnym polu 80%, zatrzymuje górnika i dopiero po jej zakończeniu pozwala kopać dalej. Nie buduje zdalnie, nie nalicza opłaty ponownie i nadal nie marnuje podpory na końcu trasy. Brak zasobów zachowuje wcześniejsze ryzyko śmiertelnego zawalenia. + +Stone w mapie stresu korzysta ze zwykłego renderowania bez procentu stresu i bez podświetlenia kolejki; fokus zachowuje dotychczasowy wygląd. Widoczny i dostępny skrót t został usunięty z samego fokusu na Minerze, a dodany do trybu zaznaczenia przez Space. Enter w bocznym panelu tylko wybiera zadanie i wraca do rysowania, nie zatwierdza kolejki. Jeśli zmiana na Moving unieważnia szkic przez kamień, configure_selected_task resetuje szkic do początku trasy, zachowując zaznaczenie jednostki. + +Dodano 4 regresje i dostosowano testy poprzedniej natychmiastowej budowy: pełny czas i zachowanie podpory do końca rozbiórki, zatrzymanie kolejki budowniczego, pauza bez budowniczego i wznowienie, jedna praca na górnika, normalny kamień przy wszystkich zoomach i kombinacjach fokusu oraz t dopiero po Space i reset nieprawidłowego szkicu Moving. Wszystkie 106 testów przeszły w debug i release; cargo check --all-targets --locked i cargo clippy --all-targets --locked przez nix develop przeszły. Clippy nadal ma 47 wcześniejszych ostrzeżeń. Zastosowano cargo fmt; git diff --check przeszedł. Uzupełniono README, CODE_MAP, TESTING i AGENTS. Nie wykonywano ręcznej rozgrywki ani budowy paczek. + +## 2026-10-24 — stres tuneli, podpory, zawalenia i wybór zadania + +Zachowano zmianę użytkownika: domyślny stres Tunnel to 20%. Podpora jest ulepszeniem roof_support, oznaczanym przez Structure::get_level jako T, z ceną 10 Wood + 2 Metal. Dodano zakładkę Support w budowaniu: Enter natychmiast stawia podporę na tunelu przy własnym Minerze na polu lub obok; Delete usuwa ją bez zwrotu kosztu i natychmiast rozstrzyga kaskadę. Walidacja terenu, budowniczego i zasobów jest wspólna dla TUI i automatu, bez zdalnego budowania ani powtórnych opłat. + +board_stress.rs przelicza najkrótsze połączenia w czterech kierunkach: bazy startują z 0, podpory z 20, każdy kolejny krok dodaje 20 do limitu 100. Gotowe Mine przepuszczają połączenie; odłączone tunele mają stres 100. Osiągnięcie 100 zamienia tunel w nieprzechodni CollapsedTunnel (#, 750 durability), usuwa znajdującą się tam jednostkę i ponawia przeliczenie do stabilizacji. Wykonywane i oczekujące zadania zasypanych jednostek giną razem z nimi; rysowana trasa ginącej jednostki również jest czyszczona. Gruz można wykopać ponownie; obrażenia bazują na 1000 HP kamienia, więc usuwa się go szybciej niż zwykły kamień. Tunel ma nadal 500 HP. + +Tick korzysta z advance_turn_with_events i rekordów Excavation. Po wykopach, przed rozstrzygnięciem zawaleń i rekrutacją, automat opłaca podporę na poprzednim polu 80% wyłącznie wtedy, gdy kolejny wykop bez niej osiągnąłby 100%. Pierwsza podpora na prostej trasie jest cztery pola od bazy, kolejne co trzy; nie ma zbędnej podpory na końcu trasy. Ustawienie auto_supports jest domyślnie włączone i kopiowane do nowych zadań; konwersje Digging/Moving je zachowują. Brak zasobów nie zatrzymuje kopania — może zakończyć się śmiercią górnika. Low-level advance_turn pozostaje samym krokiem jednostek; pełne rozliczenie stresu i gospodarki jest w SkirmishState::tick_update. + +Dodano boczny panel t, który zajmuje prawy obszar layoutu i nie przykrywa środka mapy. Strzałki wybierają Digging/Moving, Enter zaczyna rysowanie, t zamyka panel; Esc jest ignorowany. a przełącza automatyczne podpory dla nowych zadań Digging, także podczas rysowania. Jawnie wybrany Moving ogranicza trasę do pól przechodnich i nie zaczyna kopać, gdy droga później się zawali. Spacja pozostaje skrótem domyślnej trasy z kopaniem. Resize uwzględnia panel zadania. Skrót s przełącza widok stresu: same procenty, gradient zielony/czerwony, zachowany kolor fokusu, bez jednostek, oznaczeń tras i innych postępów. W małym zoomie 100 nie ma sufiksu %. Renderer nie zmienia modelu. + +Dodano 13 testów w tests/tunnel_stress.rs: odległości, cztery kierunki podpór, alternatywne podparcie, kaskady i śmierć obu stron, walidacja i koszty, minimalne rozmieszczenie auto-podpór, brak środków/wyłączenie automatu, konwersje opcji, gruz i ponowne kopanie, zakładka Support, widok stresu we wszystkich zoomach, fokus, geometria bocznego panelu oraz Moving bez kopania. Starszy test mieszanej trasy ma teraz podporę w odizolowanym końcowym tunelu, aby nadal sprawdzał podział Moving/Digging bez niezamierzonego zawalenia testowej planszy. + +Wszystkie 102 testy przeszły w debug i release; cargo check --all-targets --locked i cargo clippy --all-targets --locked przez nix develop przeszły. Pozostało 47 dotychczasowych ostrzeżeń Clippy. Zastosowano cargo fmt, git diff --check przeszedł. Uzupełniono README, CODE_MAP, TESTING i AGENTS. Nie wykonywano ręcznej rozgrywki, odsłuchu ani przebudowy paczek. Ceny są wstępne; szansa przeżycia i ratowanie jednostek nie są częścią tej implementacji. + ## 2026-10-23 — zakładki budowania i rekrutacji Dodano paski Tabs z wyróżnieniem LightMagenta oraz strzałki Left/Right przełączające opcje w aktywnym oknie bez ruchu mapy. SkirmishState przechowuje osobne indeksy i listy opcji (obecnie wyłącznie Mine oraz Miner); wybór zawija na końcach listy. Przy jednej pozycji strzałki zachowują ten sam wybór. Nie dodano budynków ani jednostek. Enter, Delete i zamykanie b/r zachowują dotychczasowe działanie; Esc pozostaje ignorowany. Dodanie przyszłych typów wymaga powiązania ich szczegółów i akcji z wyborem, zgodnie z CODE_MAP. diff --git a/docs/TESTING.md b/docs/TESTING.md index 678a1cf..2595f02 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -10,6 +10,8 @@ Testy korzystają z wbudowanego mechanizmu Rust, Clap i `ratatui::backend::TestB ## Zakres +`tests/tunnel_stress.rs` obejmuje 17 testów: odległość od bazy, propagacja podpór w czterech kierunkach, alternatywne podpory, śmiertelna kaskada i gruz, koszt oraz blokada zdalnego budowania, auto-podpory w ostatnich bezpiecznych miejscach, brak środków, zachowanie opcji przy konwersji zadań, szybsze ponowne kopanie, zakładka Support, widok stresu i boczny panel zadań, wybór Moving bez kopania oraz położenie panelu poza środkiem planszy. Regresje obejmują także pełny czas budowy i rozbiórki, zatrzymanie pracującego górnika, pauzę bez budowniczego, brak dwóch równoległych prac jednego górnika, zwykły kamień w widoku stresu oraz t dostępne dopiero po Space. + | Plik | Liczba | Zachowania | |---|---:|---| | `tests/cli.rs` | 5 | Domyślne ustawienia, granice wszystkich opcji liczbowych, XP (także NaN i infinity), błędne enumy, flagi audio, uruchomienie binarki z help/version i błędnym argumentem | @@ -23,7 +25,7 @@ Testy korzystają z wbudowanego mechanizmu Rust, Clap i `ratatui::backend::TestB | `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: 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. +Łącznie: 106 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: @@ -61,7 +63,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 89 testów można wykonać z kontrolą przepełnień w profilu produkcyjnym: +Release ma `overflow-checks = true`. Ten sam zestaw 106 testów można wykonać z kontrolą przepełnień w profilu produkcyjnym: ```sh nix develop -c cargo test --all-targets --release --locked diff --git a/flake.nix b/flake.nix index ad69741..bf66b18 100644 --- a/flake.nix +++ b/flake.nix @@ -59,6 +59,9 @@ buildInputs = with pkgs; [ rustToolchain alsa-lib + bash + gitea-actions-runner + docker ]; nativeBuildInputs = with pkgs; [pkg-config]; @@ -70,6 +73,12 @@ echo " nix run - Run production version" echo " nix build .#develop - Build development version" echo " nix run .#develop - Run development version" + echo "" + echo "Gitea workflows:" + echo " gitea-runner exec -W .gitea/workflows/rust.yml --event push -j check" + echo " gitea-runner exec -W .gitea/workflows/nix.yml --event push -j build" + echo " gitea-runner exec -W .gitea/workflows/packages.yml --event push -j linux" + echo " gitea-runner exec -W .gitea/workflows/packages.yml --event push -j aur" ''; }; }; diff --git a/src/app/app.rs b/src/app/app.rs index 7939a6c..81931dc 100644 --- a/src/app/app.rs +++ b/src/app/app.rs @@ -56,10 +56,10 @@ impl App { if let Ok((_, _)) = resize_rx.try_recv() { let window_area: Rect = self.window_area; if let Some(state) = self.states_mut() { - state - .skirmish - .board - .change_resize(&window_area, state.skirmish.side_panel); + state.skirmish.board.change_resize( + &window_area, + state.skirmish.side_panel || state.skirmish.task_view, + ); } } diff --git a/src/app/keybindings/keybindings.rs b/src/app/keybindings/keybindings.rs index a89c64a..4c3c6e3 100644 --- a/src/app/keybindings/keybindings.rs +++ b/src/app/keybindings/keybindings.rs @@ -35,6 +35,9 @@ pub enum Action { Building, /// Open or close the recruitment view. Recruitment, + Tasks, + AutoSupports, + Stress, /// Unmark last selected cell. Backspace, /// Cancel marking cell. @@ -112,6 +115,33 @@ pub struct KeyBinding { /// The slice is ordered primarily for readability; the runtime lookup scans /// this slice linearly, which is acceptable given the modest size of the list. static KEYBINDINGS: &[KeyBinding] = &[ + KeyBinding { + action: Action::Tasks, + code: KeyCode::Char('t'), + kind: KeyEventKind::Press, + modifiers: KeyModifiers::NONE, + group: Group::Opener, + symbol: "t", + description: "Tasks", + }, + KeyBinding { + action: Action::AutoSupports, + code: KeyCode::Char('a'), + kind: KeyEventKind::Press, + modifiers: KeyModifiers::NONE, + group: Group::Opener, + symbol: "a", + description: "Auto supports", + }, + KeyBinding { + action: Action::Stress, + code: KeyCode::Char('s'), + kind: KeyEventKind::Press, + modifiers: KeyModifiers::NONE, + group: Group::Opener, + symbol: "s", + description: "Stress view", + }, KeyBinding { action: Action::Recruitment, code: KeyCode::Char('r'), diff --git a/src/app/keybindings/skirmish.rs b/src/app/keybindings/skirmish.rs index b02356a..9de3731 100644 --- a/src/app/keybindings/skirmish.rs +++ b/src/app/keybindings/skirmish.rs @@ -13,11 +13,26 @@ use crate::app::{ use ratatui::{crossterm::event::KeyEvent, layout::Rect}; pub fn available_skirmish_actions(state: &SkirmishState) -> Vec { + if state.task_view { + let mut actions: Vec = vec![ + Action::Tasks, + Action::Left, + Action::Right, + Action::Enter, + Action::Stress, + Action::Quit2, + ]; + if state.selected_task == 0 { + actions.push(Action::AutoSupports); + } + return actions; + } if state.recruitment_view { let mut actions: Vec = vec![ Action::Recruitment, Action::Left, Action::Right, + Action::Stress, Action::Quit2, ]; if state.can_recruit_miner(state.board.get_focused_cell().get_coords(), Players::Player) { @@ -26,9 +41,25 @@ pub fn available_skirmish_actions(state: &SkirmishState) -> Vec { return actions; } if state.building_view { - let mut actions: Vec = - vec![Action::Building, Action::Left, Action::Right, Action::Quit2]; + let mut actions: Vec = vec![ + Action::Building, + Action::Left, + Action::Right, + Action::Stress, + Action::Quit2, + ]; let coords: (usize, usize) = state.board.get_focused_cell().get_coords(); + if state.selected_building == 1 { + if state.can_build_support(coords, Players::Player) { + actions.push(Action::Enter); + } + if state.can_manage_support(coords, Players::Player) + && matches!(state.board.cells[coords.0][coords.1].get_structure(), Structures::Tunnel(tunnel) if tunnel.has_support() && tunnel.support_work().is_none()) + { + actions.push(Action::Delete); + } + return actions; + } if let Structures::MineConstruction(site) = state.board.get_ref_cell(coords.0, coords.1).get_structure() && site.get_owner() == Players::Player @@ -48,7 +79,12 @@ pub fn available_skirmish_actions(state: &SkirmishState) -> Vec { Action::Esc, Action::Tab, Action::Quit2, + Action::Stress, + Action::Tasks, ]); + if !state.board.movement_only { + actions.push(Action::AutoSupports); + } return actions; } actions.extend([ @@ -61,6 +97,7 @@ pub fn available_skirmish_actions(state: &SkirmishState) -> Vec { Action::Mute, Action::Quit, Action::Esc, + Action::Stress, ]); if state.board.zoom_level != ZoomLevel::ZoomedIn { actions.push(Action::ZoomIn); @@ -85,11 +122,41 @@ pub fn available_skirmish_actions(state: &SkirmishState) -> Vec { } pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () { + let window_area: Rect = app.window_area; if let Some(action) = event_to_action(key_event) { let Some(states) = app.states_mut() else { common_keybindings(app, action); return; }; + if action == Action::Stress { + states.skirmish.stress_view = !states.skirmish.stress_view; + return; + } + if states.skirmish.task_view { + match action { + Action::Tasks | Action::Enter => { + states.skirmish.task_view = false; + if action == Action::Enter { + states + .skirmish + .board + .configure_selected_task(states.skirmish.selected_task == 1); + } + states + .skirmish + .board + .change_resize(&window_area, states.skirmish.side_panel); + } + Action::Left => states.skirmish.cycle_popup_option(false), + Action::Right => states.skirmish.cycle_popup_option(true), + Action::AutoSupports if states.skirmish.selected_task == 0 => { + states.skirmish.board.auto_supports = !states.skirmish.board.auto_supports + } + Action::Esc => (), + _ => common_keybindings(app, action), + } + return; + } if states.skirmish.recruitment_view { match action { Action::Recruitment => states.skirmish.recruitment_view = false, @@ -114,16 +181,24 @@ pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () { Action::Enter => { let coords: (usize, usize) = states.skirmish.board.get_focused_cell().get_coords(); - states - .skirmish - .start_mine_construction(coords, Players::Player); + if states.skirmish.selected_building == 1 { + states.skirmish.build_support(coords, Players::Player); + } else { + states + .skirmish + .start_mine_construction(coords, Players::Player); + } } Action::Delete => { let coords: (usize, usize) = states.skirmish.board.get_focused_cell().get_coords(); - states - .skirmish - .cancel_mine_construction(coords, Players::Player); + if states.skirmish.selected_building == 1 { + states.skirmish.remove_support(coords, Players::Player); + } else { + states + .skirmish + .cancel_mine_construction(coords, Players::Player); + } } _ => common_keybindings(app, action), } @@ -213,9 +288,19 @@ pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () { } ZoomLevel::ZoomedOut => {} }, - Action::Space => board.toggle_marking(), + Action::Space => { + if !board.marked_cells.marking_cells { + board.movement_only = false; + } + board.toggle_marking(); + } Action::Building => states.skirmish.building_view = true, Action::Recruitment => states.skirmish.recruitment_view = true, + Action::Tasks => { + states.skirmish.task_view = true; + board.change_resize(&window_area, true); + } + Action::AutoSupports => board.auto_supports = !board.auto_supports, Action::Backspace if board.marked_cells.marking_cells => board.pop_marked_cell(), Action::Backspace => { board.cancel_focused_tasks(false); diff --git a/src/app/state.rs b/src/app/state.rs index fbfa0c9..46ab25d 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -42,6 +42,9 @@ impl GameStates { recruitment_view: false, selected_building: 0, selected_recruit: 0, + task_view: false, + stress_view: false, + selected_task: 0, }, perk_decks: PerkDecksState { id: 2, diff --git a/src/app/states/skirmish.rs b/src/app/states/skirmish.rs index 517f4ab..07f546d 100644 --- a/src/app/states/skirmish.rs +++ b/src/app/states/skirmish.rs @@ -1,8 +1,9 @@ use crate::app::states::skirmish_states::{ - BoardState, Players, Resources, + BoardState, Excavation, Players, Resources, resources::{MINE_PRODUCTION_TICKS, WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_TICKS}, - structures::{BuildingTerrain, Mine, Structure, Structures}, - units::MinerUnit, + structures::{BuildingTerrain, Mine, Structure, Structures, Tunnel}, + tasks::{Task, Tasks}, + units::{MinerUnit, Unit}, }; use crate::app::widgets::CellWidget; @@ -19,14 +20,163 @@ pub struct SkirmishState { pub recruitment_view: bool, pub selected_building: usize, pub selected_recruit: usize, + pub task_view: bool, + pub stress_view: bool, + pub selected_task: usize, } impl SkirmishState { - pub const BUILDING_OPTIONS: &'static [&'static str] = &["Mine"]; + pub const BUILDING_OPTIONS: &'static [&'static str] = &["Mine", "Support"]; pub const RECRUITMENT_OPTIONS: &'static [&'static str] = &["Miner"]; + pub const TASK_OPTIONS: &'static [&'static str] = &["Digging", "Moving"]; + + pub fn can_manage_support(&self, coords: (usize, usize), owner: Players) -> bool { + owner != Players::Unclaimed + && self + .board + .cells + .get(coords.0) + .and_then(|row: &Vec| -> Option<&CellWidget> { row.get(coords.1) }) + .is_some_and(|cell: &CellWidget| -> bool { + matches!(cell.get_structure(), Structures::Tunnel(_)) + && ((cell.get_ref_option_unit().get_owner() == owner + && cell.get_ref_option_unit().get_can_build()) + || self.board.has_adjacent_builder(coords, owner)) + }) + } + + pub fn can_build_support(&self, coords: (usize, usize), owner: Players) -> bool { + let resources: &Resources = match owner { + Players::Player => &self.player_resources, + Players::Enemy => &self.enemy_resources, + Players::Unclaimed => return false, + }; + self.can_manage_support(coords, owner) + && resources.can_afford(Tunnel::SUPPORT_COST) + && matches!(self.board.cells[coords.0][coords.1].get_structure(), Structures::Tunnel(tunnel) if !tunnel.has_support() && tunnel.support_work().is_none()) + } + + pub fn build_support(&mut self, coords: (usize, usize), owner: Players) -> bool { + if !self.can_build_support(coords, owner) { + return false; + } + let Structures::Tunnel(mut tunnel) = self.board.cells[coords.0][coords.1].get_structure() + else { + return false; + }; + let resources: &mut Resources = if owner == Players::Player { + &mut self.player_resources + } else { + &mut self.enemy_resources + }; + if !resources.spend(Tunnel::SUPPORT_COST) { + return false; + } + tunnel.start_support_work(owner, false); + self.board.cells[coords.0][coords.1].set_structure(Structures::Tunnel(tunnel)); + true + } + + pub fn remove_support(&mut self, coords: (usize, usize), owner: Players) -> bool { + if !self.can_manage_support(coords, owner) { + return false; + } + let Structures::Tunnel(mut tunnel) = self.board.cells[coords.0][coords.1].get_structure() + else { + return false; + }; + if !tunnel.has_support() || tunnel.support_work().is_some() { + return false; + } + tunnel.start_support_work(owner, true); + self.board.cells[coords.0][coords.1].set_structure(Structures::Tunnel(tunnel)); + true + } + + pub fn advance_support_work(&mut self) -> () { + let mut workers: std::collections::HashSet<(usize, usize)> = + std::collections::HashSet::new(); + let mut finished: bool = false; + let mut removed: bool = false; + for row in 0..self.board.map_height { + for col in 0..self.board.map_width { + let Structures::Tunnel(mut tunnel) = self.board.cells[row][col].get_structure() + else { + continue; + }; + let Some(work) = tunnel.support_work() else { + continue; + }; + let Some(worker) = self.board.support_worker((row, col), work.owner) else { + continue; + }; + if !workers.insert(worker) { + continue; + } + if let Some(removing) = tunnel.advance_support_work() { + finished = true; + removed |= removing; + } + self.board.cells[row][col].set_structure(Structures::Tunnel(tunnel)); + } + } + if removed { + self.board.resolve_collapses(); + } else if finished { + self.board.recalculate_stress(); + } + } + + fn prepare_automatic_supports(&mut self) -> () { + let mut candidates: Vec<((usize, usize), Players)> = Vec::new(); + for (row, cells) in self.board.cells.iter().enumerate() { + for (col, cell) in cells.iter().enumerate() { + if !matches!(cell.get_structure(), Structures::Tunnel(tunnel) if !tunnel.has_support() && tunnel.get_stress() == 80) + { + continue; + } + if self.board.working_on_support((row, col)) + || (self.board.marked_cells.marking_cells + && self.board.marked_cells.selected_unit.get_coords() == (row, col)) + { + continue; + } + let Some(unit) = cell.get_ref_option_unit() else { + continue; + }; + let Some(task) = unit.get_ref_tasks().front() else { + continue; + }; + let Some(&(r, c)) = task.get_path().front() else { + continue; + }; + if task.auto_supports() + && self.can_build_support((row, col), unit.get_owner()) + && (matches!(task, Tasks::Digging(_)) + || matches!(task, Tasks::Moving(moving) if moving.dig_if_blocked)) + && r < self.board.map_height + && c < self.board.map_width + && row.abs_diff(r) + col.abs_diff(c) == 1 + && self.board.cells[r][c].get_ref_option_unit().is_none() + && matches!( + self.board.cells[r][c].get_structure(), + Structures::Stone(_) | Structures::CollapsedTunnel(_) + ) + && self.board.prospective_tunnel_stress((r, c)) >= 100 + { + candidates.push(((row, col), unit.get_owner())); + } + } + } + for (coords, owner) in candidates { + self.build_support(coords, owner); + } + } pub fn cycle_popup_option(&mut self, next: bool) -> () { - let (selected, count): (&mut usize, usize) = if self.recruitment_view { + let (selected, count): (&mut usize, usize) = if self.task_view { + (&mut self.selected_task, Self::TASK_OPTIONS.len()) + } else if self.recruitment_view { (&mut self.selected_recruit, Self::RECRUITMENT_OPTIONS.len()) } else if self.building_view { (&mut self.selected_building, Self::BUILDING_OPTIONS.len()) @@ -114,7 +264,12 @@ impl SkirmishState { } pub fn tick_update(&mut self) -> () { - self.board.advance_turn(); + self.advance_support_work(); + self.prepare_automatic_supports(); + let excavated: Vec = self.board.advance_turn_with_events(); + if !excavated.is_empty() { + self.board.resolve_collapses(); + } self.board.advance_recruitment(); self.board.advance_construction(); diff --git a/src/app/states/skirmish_states/board.rs b/src/app/states/skirmish_states/board.rs index 43c4c41..0c152ca 100644 --- a/src/app/states/skirmish_states/board.rs +++ b/src/app/states/skirmish_states/board.rs @@ -17,6 +17,13 @@ use crate::app::{ use ratatui::layout::Rect; use std::collections::VecDeque; +pub struct Excavation { + pub destination: (usize, usize), + pub origin: (usize, usize), + pub owner: Players, + pub auto_supports: bool, +} + #[derive(Debug, Clone, PartialEq)] pub struct BoardState { cells_area: Rect, @@ -32,6 +39,8 @@ pub struct BoardState { pub zoom_level: ZoomLevel, focused_cell: FocusedCell, pub marked_cells: MarkedCells, + pub auto_supports: bool, + pub movement_only: bool, } impl BoardState { @@ -145,6 +154,7 @@ impl BoardState { } pub fn advance_construction(&mut self) -> () { + let mut opened: bool = false; for row in 0..self.cells.len() { for col in 0..self.cells[row].len() { if let Structures::MineConstruction(mut site) = self.cells[row][col].get_structure() @@ -152,6 +162,7 @@ impl BoardState { { site.advance(); let structure: Structures = if let Some(mine) = site.into_mine() { + opened = true; Structures::Mine(mine) } else { Structures::MineConstruction(site) @@ -160,6 +171,9 @@ impl BoardState { } } } + if opened { + self.recalculate_stress(); + } } pub fn new( @@ -256,6 +270,8 @@ impl BoardState { zoom_level, focused_cell, marked_cells, + auto_supports: true, + movement_only: false, } } @@ -333,6 +349,29 @@ impl BoardState { self.focused_cell.set_focused_cell(new); } + pub fn configure_selected_task(&mut self, movement_only: bool) -> () { + if !self.marked_cells.marking_cells { + return; + } + if movement_only + && self + .marked_cells + .marked_cells + .iter() + .skip(1) + .any(|&coords: &(usize, usize)| -> bool { !self.is_walkable(coords) }) + { + let origin: (usize, usize) = self.marked_cells.selected_unit.get_coords(); + let focused: (usize, usize) = self.focused_cell.get_coords(); + self.end_marking_cells(true); + self.get_mut_cell(focused.0, focused.1).set_selected(false); + self.focused_cell.set_focused_cell(origin); + self.marked_cells.marking_cells = true; + self.start_marking_cells(); + } + self.movement_only = movement_only; + } + pub fn start_marking_cells(&mut self) { let (row, col) = self.focused_cell.get_coords(); @@ -386,12 +425,17 @@ impl BoardState { let tasks: VecDeque = 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]) { + let path: VecDeque<(usize, usize)> = segment.iter().copied().collect(); + let mut task: Tasks = if self.movement_only || self.is_walkable(segment[0]) { Tasks::Moving(MovingTask::new(path)) } else { Tasks::Digging(DiggingTask::new(path, tick_increase)) + }; + task.set_auto_supports(self.auto_supports); + if let Tasks::Moving(moving) = &mut task { + moving.dig_if_blocked = !self.movement_only; } + task }) .collect(); if let Some(unit) = self.get_mut_cell(row, col).get_mut_option_unit().as_mut() { @@ -401,6 +445,7 @@ impl BoardState { self.marked_cells.selected_unit = None; self.marked_cells.marked_cells.clear(); + self.movement_only = false; } fn max_offset(map_size: usize, size: usize) -> usize { @@ -490,6 +535,10 @@ impl BoardState { pub fn change_focused_cell(&mut self, direction: MoveFocusedCell) { let old_cell: (usize, usize) = self.focused_cell.get_coords(); let new_cell: (usize, usize) = self.focused_cell.move_focused_cell(direction); + if self.marked_cells.marking_cells && self.movement_only && !self.is_walkable(new_cell) { + self.focused_cell.set_focused_cell(old_cell); + return; + } self.get_mut_cell(old_cell.0, old_cell.1) .set_selected(false); @@ -519,7 +568,12 @@ impl BoardState { true } - pub fn advance_turn(&mut self) { + pub fn advance_turn(&mut self) -> () { + let _: Vec = self.advance_turn_with_events(); + } + + pub fn advance_turn_with_events(&mut self) -> Vec { + let mut excavated: Vec = Vec::new(); // Snapshot positions so moving to a later cell cannot grant another turn. let active: Vec<(usize, usize)> = self .cells @@ -536,17 +590,18 @@ impl BoardState { .collect(); for (row, col) in active { + if self.working_on_support((row, col)) { + continue; + } // 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 Some(mut unit) = self.get_mut_cell(row, col).get_mut_option_unit().take() else { + continue; + }; let mut task: Tasks = unit.get_mut_tasks().pop_front().unwrap(); let mut destination = (row, col); @@ -570,24 +625,38 @@ impl BoardState { (Tasks::Digging(dig), _) if walkable => { Tasks::Moving(MovingTask::from_digging(dig)) } - (Tasks::Moving(moving), Structures::Stone(_)) => Tasks::Digging( - DiggingTask::from_moving(moving, unit.get_digging_power()), - ), + ( + Tasks::Moving(moving), + Structures::Stone(_) | Structures::CollapsedTunnel(_), + ) if moving.dig_if_blocked => Tasks::Digging(DiggingTask::from_moving( + moving, + unit.get_digging_power(), + )), (task, _) => task, }; match (&mut task, structure) { - (Tasks::Digging(dig), Structures::Stone(mut stone)) => { - let max: f32 = f32::from(stone.get_max_durability()); + ( + Tasks::Digging(dig), + Structures::Stone(_) | Structures::CollapsedTunnel(_), + ) => { + let mut obstacle: Structures = structure; + let max: f32 = f32::from(Stone::new().get_max_durability()); let before: u16 = (dig.get_progress() * max / 100.0) as u16; dig.progress(); let after: u16 = (dig.get_progress() * max / 100.0) as u16; - stone.take_damage(after.saturating_sub(before)); - if stone.get_durability() == 0 { + obstacle.take_damage(after.saturating_sub(before)); + if obstacle.get_durability() == 0 { cell.set_structure(Structures::Tunnel(Tunnel::new())); + excavated.push(Excavation { + destination: (target_row, target_col), + origin: (row, col), + owner: unit.get_owner(), + auto_supports: dig.auto_supports(), + }); destination = (target_row, target_col); dig.pop_front(); } else { - cell.set_structure(Structures::Stone(stone)); + cell.set_structure(obstacle); } } (Tasks::Moving(moving), _) if walkable => { @@ -609,5 +678,6 @@ impl BoardState { .get_mut_cell(destination.0, destination.1) .get_mut_option_unit() = Some(unit); } + excavated } } diff --git a/src/app/states/skirmish_states/board_stress.rs b/src/app/states/skirmish_states/board_stress.rs new file mode 100644 index 0000000..cae9d77 --- /dev/null +++ b/src/app/states/skirmish_states/board_stress.rs @@ -0,0 +1,166 @@ +use super::{ + BoardState, Players, + structures::{CollapsedTunnel, Structure, Structures}, + units::{Unit, Units}, +}; +use crate::app::widgets::CellWidget; +use std::collections::VecDeque; + +impl BoardState { + fn stress_distances(&self) -> Vec> { + let mut distance: Vec> = vec![vec![100; self.map_width]; self.map_height]; + let mut queue: VecDeque<(usize, usize)> = VecDeque::new(); + for (row, cells) in self.cells.iter().enumerate() { + for (col, cell) in cells.iter().enumerate() { + let source: Option = match cell.get_structure() { + Structures::Base(_) => Some(0), + Structures::Tunnel(tunnel) if tunnel.has_support() => Some(20), + _ => None, + }; + if let Some(stress) = source { + distance[row][col] = stress; + queue.push_back((row, col)); + } + } + } + while let Some((row, col)) = queue.pop_front() { + let next: u8 = distance[row][col].saturating_add(20).min(100); + for (r, c) in [ + (row.wrapping_sub(1), col), + (row + 1, col), + (row, col.wrapping_sub(1)), + (row, col + 1), + ] { + if r < self.map_height + && c < self.map_width + && next < distance[r][c] + && matches!( + self.cells[r][c].get_structure(), + Structures::Tunnel(_) | Structures::Base(_) | Structures::Mine(_) + ) + { + distance[r][c] = next; + queue.push_back((r, c)); + } + } + } + distance + } + + pub fn prospective_tunnel_stress(&self, (row, col): (usize, usize)) -> u8 { + let distance: Vec> = self.stress_distances(); + [ + (row.wrapping_sub(1), col), + (row + 1, col), + (row, col.wrapping_sub(1)), + (row, col + 1), + ] + .into_iter() + .filter_map(|(r, c): (usize, usize)| -> Option { + distance + .get(r) + .and_then(|line: &Vec| -> Option<&u8> { line.get(c) }) + .copied() + }) + .min() + .unwrap_or(100) + .saturating_add(20) + .min(100) + } + + pub fn recalculate_stress(&mut self) -> () { + let distance: Vec> = self.stress_distances(); + for (row, cells) in self.cells.iter_mut().enumerate() { + for (col, cell) in cells.iter_mut().enumerate() { + if let Structures::Tunnel(mut tunnel) = cell.get_structure() { + tunnel.set_stress(distance[row][col]); + cell.set_structure(Structures::Tunnel(tunnel)); + } + } + } + } + + pub fn support_worker( + &self, + (row, col): (usize, usize), + owner: Players, + ) -> Option<(usize, usize)> { + if owner == Players::Unclaimed { + return None; + } + [ + (row, col), + (row.wrapping_sub(1), col), + (row + 1, col), + (row, col.wrapping_sub(1)), + (row, col + 1), + ] + .into_iter() + .find(|&(r, c): &(usize, usize)| -> bool { + self.cells + .get(r) + .and_then(|line: &Vec| -> Option<&CellWidget> { line.get(c) }) + .and_then(|cell: &CellWidget| -> Option<&Units> { + cell.get_ref_option_unit().as_ref() + }) + .is_some_and(|unit: &Units| -> bool { + unit.get_owner() == owner && unit.get_can_build() + }) + }) + } + + pub fn working_on_support(&self, (row, col): (usize, usize)) -> bool { + [ + (row, col), + (row.wrapping_sub(1), col), + (row + 1, col), + (row, col.wrapping_sub(1)), + (row, col + 1), + ] + .into_iter() + .any(|(r, c): (usize, usize)| -> bool { + self.cells + .get(r) + .and_then(|line: &Vec| -> Option<&CellWidget> { line.get(c) }) + .is_some_and(|cell: &CellWidget| -> bool { + if let Structures::Tunnel(tunnel) = cell.get_structure() + && let Some(work) = tunnel.support_work() + { + self.support_worker((r, c), work.owner) == Some((row, col)) + } else { + false + } + }) + }) + } + + pub fn resolve_collapses(&mut self) -> usize { + let mut total: usize = 0; + loop { + self.recalculate_stress(); + let mut collapsed: usize = 0; + for row in &mut self.cells { + for cell in row { + if matches!(cell.get_structure(), Structures::Tunnel(tunnel) if tunnel.get_stress() >= 100) + { + cell.set_structure(Structures::CollapsedTunnel(CollapsedTunnel::new())); + cell.get_mut_option_unit().take(); + collapsed += 1; + } + } + } + total += collapsed; + if collapsed == 0 { + break; + } + } + if self.marked_cells.marking_cells { + let (row, col): (usize, usize) = self.marked_cells.selected_unit.get_coords(); + if self.cells[row][col].get_ref_option_unit().is_none() { + self.marked_cells.marking_cells = false; + self.end_marking_cells(true); + } + } + total + } +} diff --git a/src/app/states/skirmish_states/mod.rs b/src/app/states/skirmish_states/mod.rs index 49355a1..29f3ea5 100644 --- a/src/app/states/skirmish_states/mod.rs +++ b/src/app/states/skirmish_states/mod.rs @@ -1,4 +1,5 @@ mod board; +mod board_stress; mod focused_cell; mod game_mode; mod marked_cells; @@ -10,7 +11,7 @@ pub mod tasks; pub mod units; pub mod zoom_level; -pub use board::BoardState; +pub use board::{BoardState, Excavation}; pub use focused_cell::{FocusedCell, MoveFocusedCell}; pub use game_mode::GameMode; pub use marked_cells::MarkedCells; diff --git a/src/app/states/skirmish_states/structures/collapsed_tunnel.rs b/src/app/states/skirmish_states/structures/collapsed_tunnel.rs new file mode 100644 index 0000000..2b27f6e --- /dev/null +++ b/src/app/states/skirmish_states/structures/collapsed_tunnel.rs @@ -0,0 +1,49 @@ +use crate::app::states::skirmish_states::{Players, structures::Structure}; +use ratatui::style::Color; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CollapsedTunnel { + durability: u16, +} + +impl Default for CollapsedTunnel { + fn default() -> Self { + Self::new() + } +} + +impl CollapsedTunnel { + pub fn new() -> Self { + Self { durability: 750 } + } + pub fn take_damage(&mut self, damage: u16) -> () { + self.durability = self.durability.saturating_sub(damage); + } +} + +impl Structure for CollapsedTunnel { + fn get_tag(&self) -> char { + '#' + } + fn get_color(&self) -> Color { + Color::DarkGray + } + fn get_level(&self) -> char { + ' ' + } + fn get_stress(&self) -> u8 { + 100 + } + fn get_durability(&self) -> u16 { + self.durability + } + fn get_max_durability(&self) -> u16 { + 750 + } + fn get_name(&self) -> &'static str { + "Collapsed tunnel" + } + fn get_owner(&self) -> Players { + Players::Unclaimed + } +} diff --git a/src/app/states/skirmish_states/structures/mod.rs b/src/app/states/skirmish_states/structures/mod.rs index b2da252..05e4c13 100644 --- a/src/app/states/skirmish_states/structures/mod.rs +++ b/src/app/states/skirmish_states/structures/mod.rs @@ -1,4 +1,5 @@ mod base; +mod collapsed_tunnel; mod mine; mod mine_construction; mod ore; @@ -8,6 +9,7 @@ mod structures_enum; mod tunnel; pub use base::BaseBuilding; +pub use collapsed_tunnel::CollapsedTunnel; pub use mine::Mine; pub use mine_construction::MineConstruction; pub use ore::Ore; diff --git a/src/app/states/skirmish_states/structures/structures_enum.rs b/src/app/states/skirmish_states/structures/structures_enum.rs index bcfbd34..e740233 100644 --- a/src/app/states/skirmish_states/structures/structures_enum.rs +++ b/src/app/states/skirmish_states/structures/structures_enum.rs @@ -1,6 +1,8 @@ use crate::app::states::skirmish_states::{ Players, - structures::{BaseBuilding, Mine, MineConstruction, Ore, Stone, Structure, Tunnel}, + structures::{ + BaseBuilding, CollapsedTunnel, Mine, MineConstruction, Ore, Stone, Structure, Tunnel, + }, units::Units, }; use ratatui::{ @@ -13,12 +15,20 @@ pub enum Structures { Base(BaseBuilding), Tunnel(Tunnel), Stone(Stone), + CollapsedTunnel(CollapsedTunnel), Ore(Ore), Mine(Mine), MineConstruction(MineConstruction), } impl Structures { + pub fn take_damage(&mut self, damage: u16) -> () { + match self { + Self::Stone(stone) => stone.take_damage(damage), + Self::CollapsedTunnel(rubble) => rubble.take_damage(damage), + _ => (), + } + } pub fn can_enter(&self, unit: &Units) -> bool { match self { Self::Base(_) | Self::Tunnel(_) => true, @@ -42,6 +52,7 @@ impl Structures { Self::Base(b) => b, Self::Tunnel(t) => t, Self::Stone(s) => s, + Self::CollapsedTunnel(rubble) => rubble, Self::Ore(o) => o, Self::Mine(mine) => mine, Self::MineConstruction(site) => site, diff --git a/src/app/states/skirmish_states/structures/tunnel.rs b/src/app/states/skirmish_states/structures/tunnel.rs index b4ae309..2029948 100644 --- a/src/app/states/skirmish_states/structures/tunnel.rs +++ b/src/app/states/skirmish_states/structures/tunnel.rs @@ -1,21 +1,85 @@ -use crate::app::states::skirmish_states::{Players, structures::Structure}; -use ratatui::style::Color; +use crate::app::states::skirmish_states::{Players, Resources, structures::Structure}; +use crate::app::threads::TICK_MS; +use ratatui::{ + style::{Color, Stylize}, + text::Line, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SupportWork { + pub owner: Players, + pub removing: bool, + ticks: u16, +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Tunnel { durability: u16, stress: u8, roof_support: bool, + support_work: Option, rail: bool, lamp: bool, } impl Tunnel { + pub const SUPPORT_WORK_TICKS: u16 = 3000_u16.div_ceil(TICK_MS as u16); + pub const SUPPORT_COST: Resources = Resources { wood: 10, metal: 2 }; + + pub fn has_support(&self) -> bool { + self.roof_support + } + + pub fn support_work(&self) -> Option { + self.support_work + } + + pub fn start_support_work(&mut self, owner: Players, removing: bool) -> bool { + if owner == Players::Unclaimed + || self.support_work.is_some() + || self.roof_support != removing + { + return false; + } + self.support_work = Some(SupportWork { + owner, + removing, + ticks: 0, + }); + true + } + + pub fn support_progress(&self) -> Option { + self.support_work + .map(|work: SupportWork| -> u16 { work.ticks * 100 / Self::SUPPORT_WORK_TICKS }) + } + + pub fn advance_support_work(&mut self) -> Option { + let work: &mut SupportWork = self.support_work.as_mut()?; + work.ticks += 1; + if work.ticks < Self::SUPPORT_WORK_TICKS { + return None; + } + let removing: bool = work.removing; + self.roof_support = !removing; + self.support_work = None; + Some(removing) + } + + pub fn set_support(&mut self, supported: bool) -> () { + self.roof_support = supported; + } + + pub fn set_stress(&mut self, stress: u8) -> () { + self.stress = stress.min(100); + } + pub fn new() -> Self { Self { durability: 500, - stress: 25, + stress: 20, roof_support: false, + support_work: None, rail: false, lamp: false, } @@ -23,6 +87,25 @@ impl Tunnel { } impl Structure for Tunnel { + fn text(&self) -> Vec> { + let mut lines: Vec> = self.base_text(); + if let Some(work) = self.support_work { + lines.push( + Line::from(format!( + "{} support: {}%", + if work.removing { + "Removing" + } else { + "Building" + }, + self.support_progress().unwrap_or(0) + )) + .cyan(), + ); + } + lines + } + fn get_tag(&self) -> char { 'T' } @@ -32,7 +115,7 @@ impl Structure for Tunnel { } fn get_level(&self) -> char { - ' ' + if self.roof_support { 'T' } else { ' ' } } fn get_durability(&self) -> u16 { diff --git a/src/app/states/skirmish_states/tasks/digging.rs b/src/app/states/skirmish_states/tasks/digging.rs index 71cc00e..f593659 100644 --- a/src/app/states/skirmish_states/tasks/digging.rs +++ b/src/app/states/skirmish_states/tasks/digging.rs @@ -7,12 +7,14 @@ pub struct DiggingTask { progress: f32, tick_increase: f32, total_steps: usize, + auto_supports: bool, } impl DiggingTask { pub fn new(path: VecDeque<(usize, usize)>, tick_increase: f32) -> Self { Self { total_steps: path.len(), + auto_supports: true, path, progress: 0.0, tick_increase, @@ -30,6 +32,7 @@ impl DiggingTask { progress: 0.0, tick_increase, total_steps: task.get_total_steps(), + auto_supports: task.auto_supports(), } } @@ -39,6 +42,13 @@ impl DiggingTask { } impl Task for DiggingTask { + fn auto_supports(&self) -> bool { + self.auto_supports + } + fn set_auto_supports(&mut self, enabled: bool) -> () { + self.auto_supports = enabled; + } + fn get_icon(&self) -> char { '⛏' } diff --git a/src/app/states/skirmish_states/tasks/moving.rs b/src/app/states/skirmish_states/tasks/moving.rs index 91b3161..39326b7 100644 --- a/src/app/states/skirmish_states/tasks/moving.rs +++ b/src/app/states/skirmish_states/tasks/moving.rs @@ -11,12 +11,16 @@ pub struct MovingTask { path: VecDeque<(usize, usize)>, ticks: u16, total_steps: usize, + auto_supports: bool, + pub dig_if_blocked: bool, } impl MovingTask { pub fn new(path: VecDeque<(usize, usize)>) -> Self { Self { total_steps: path.len(), + auto_supports: true, + dig_if_blocked: true, path, ticks: 0, } @@ -25,6 +29,8 @@ impl MovingTask { pub fn from_digging(task: DiggingTask) -> Self { Self { total_steps: task.get_total_steps(), + auto_supports: task.auto_supports(), + dig_if_blocked: true, path: task.get_path().clone(), ticks: 0, } @@ -41,6 +47,13 @@ impl MovingTask { } impl Task for MovingTask { + fn auto_supports(&self) -> bool { + self.auto_supports + } + fn set_auto_supports(&mut self, enabled: bool) -> () { + self.auto_supports = enabled; + } + fn get_icon(&self) -> char { '→' } diff --git a/src/app/states/skirmish_states/tasks/task_trait.rs b/src/app/states/skirmish_states/tasks/task_trait.rs index c69965d..3ae34be 100644 --- a/src/app/states/skirmish_states/tasks/task_trait.rs +++ b/src/app/states/skirmish_states/tasks/task_trait.rs @@ -1,6 +1,10 @@ use std::collections::VecDeque; pub trait Task { + fn auto_supports(&self) -> bool; + + fn set_auto_supports(&mut self, enabled: bool) -> (); + fn get_icon(&self) -> char; fn get_path(&self) -> &VecDeque<(usize, usize)>; diff --git a/src/app/states/skirmish_states/tasks/tasks_enum.rs b/src/app/states/skirmish_states/tasks/tasks_enum.rs index ebc5c56..03398a2 100644 --- a/src/app/states/skirmish_states/tasks/tasks_enum.rs +++ b/src/app/states/skirmish_states/tasks/tasks_enum.rs @@ -26,6 +26,14 @@ impl Tasks { } impl Task for Tasks { + fn auto_supports(&self) -> bool { + self.t().auto_supports() + } + + fn set_auto_supports(&mut self, enabled: bool) -> () { + self.t_mut().set_auto_supports(enabled); + } + fn get_icon(&self) -> char { self.t().get_icon() } diff --git a/src/app/views/skirmish.rs b/src/app/views/skirmish.rs index 4e52e4b..a14e469 100644 --- a/src/app/views/skirmish.rs +++ b/src/app/views/skirmish.rs @@ -6,7 +6,7 @@ use crate::app::{ states::skirmish_states::{ BoardState, Players, resources::{WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_INTERVAL_MS}, - structures::{Mine, Structure, Structures}, + structures::{Mine, Structure, Structures, Tunnel}, units::MinerUnit, }, widgets::{BoardWidget, CellWidget, KeybindingsWidget, SidePanelWidget}, @@ -23,7 +23,6 @@ pub fn skirmish_layout(area: Rect) -> [Rect; 3] { Layout::vertical([ Constraint::Length(4), Constraint::Fill(1), - // Four movement rows plus borders keep the board geometry stable across modes. Constraint::Length(6), ]) .areas(area) @@ -100,8 +99,10 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) { let main_inner_area: Rect = main_block.inner(main_area); main_block.render(main_area, buf); - let [board_area, side_panel_area] = - skirmish_main_area_layout(main_inner_area, states.skirmish.side_panel); + let [board_area, side_panel_area] = skirmish_main_area_layout( + main_inner_area, + states.skirmish.side_panel || states.skirmish.task_view, + ); let cells_area: Rect = board_area.inner(Margin { horizontal: 1, @@ -116,9 +117,43 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) { // ), // ); - BoardWidget::new(&board).render(cells_area, buf); + BoardWidget::new(&board, states.skirmish.stress_view).render(cells_area, buf); - if states.skirmish.side_panel { + if states.skirmish.task_view { + let mut lines: Vec> = vec![ + Line::from("Enter: draw route").green(), + Line::from(format!( + "Auto supports: {}", + if board.auto_supports { "ON" } else { "OFF" } + )) + .cyan(), + Line::from("a: toggle auto supports").yellow(), + Line::from("Support: 10 Wood + 2 Metal").yellow(), + Line::from("Placed at 80% stress").gray(), + Line::from("No funds: collapse risk!").red(), + Line::from("t: close task selection").gray(), + ]; + if states.skirmish.selected_task == 1 { + lines = vec![ + Line::from("Enter: draw movement route").green(), + Line::from("Walkable cells only; no digging").cyan(), + Line::from("Collapsed route: wait, do not dig").gray(), + Line::from("t: close task selection").gray(), + ]; + } + let block: Block<'_> = Block::bordered() + .title("Unit tasks") + .padding(Padding::uniform(1)); + let [tabs_area, details_area]: [Rect; 2] = + Layout::vertical([Constraint::Length(2), Constraint::Min(0)]) + .areas(block.inner(side_panel_area)); + block.render(side_panel_area, buf); + Tabs::new(SkirmishState::TASK_OPTIONS.iter().copied()) + .select(states.skirmish.selected_task) + .highlight_style(Style::default().fg(Color::LightMagenta).bold()) + .render(tabs_area, buf); + Paragraph::new(lines).render(details_area, buf); + } else if states.skirmish.side_panel { let (row, col) = board.get_focused_cell().get_coords(); let mut is_unit_marked: bool = false; @@ -138,10 +173,37 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) { { let actions: Vec = available_skirmish_actions(&states.skirmish); - let descriptions: [(Action, &'static str); 14] = [ + let descriptions: [(Action, &'static str); 17] = [ + ( + Action::Tasks, + if states.skirmish.task_view { + "Close tasks" + } else { + "Unit tasks" + }, + ), + ( + Action::AutoSupports, + if board.auto_supports { + "Auto supports: ON" + } else { + "Auto supports: OFF" + }, + ), + ( + Action::Stress, + if states.skirmish.stress_view { + "Normal view" + } else { + "Stress view" + }, + ), ( Action::Left, - if states.skirmish.building_view || states.skirmish.recruitment_view { + if states.skirmish.building_view + || states.skirmish.recruitment_view + || states.skirmish.task_view + { "Previous option" } else { "Left" @@ -149,7 +211,10 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) { ), ( Action::Right, - if states.skirmish.building_view || states.skirmish.recruitment_view { + if states.skirmish.building_view + || states.skirmish.recruitment_view + || states.skirmish.task_view + { "Next option" } else { "Right" @@ -165,10 +230,16 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) { ), ( Action::Enter, - if states.skirmish.recruitment_view { + if states.skirmish.task_view { + "Draw route" + } else if states.skirmish.recruitment_view { "Queue Miner" } else { - "Build Mine" + if states.skirmish.selected_building == 1 { + "Build support" + } else { + "Build Mine" + } }, ), ( @@ -182,7 +253,11 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) { ( Action::Delete, if states.skirmish.building_view { - "Cancel construction" + if states.skirmish.selected_building == 1 { + "Remove support" + } else { + "Cancel construction" + } } else if !board.marked_cells.marking_cells { "Cancel all tasks" } else { @@ -261,6 +336,50 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) { Line::from("Produces: 1 Metal/s (2 with a Miner inside)").cyan(), ]; lines.extend(status); + if states.skirmish.selected_building == 1 { + lines = vec![ + Line::from(format!( + "Cost: {} Wood + {} Metal | Work time: 3s", + Tunnel::SUPPORT_COST.wood, + Tunnel::SUPPORT_COST.metal + )) + .yellow(), + Line::from("Tunnel + nearby friendly builder required").gray(), + Line::from("Stress: 20% at support, +20% per tile").cyan(), + if matches!(structure, Structures::Tunnel(tunnel) if tunnel.support_work().is_some()) + { + Line::from("Builder occupied until completion").yellow() + } else if matches!(structure, Structures::Tunnel(tunnel) if tunnel.has_support()) + && states.skirmish.can_manage_support(coords, Players::Player) + { + Line::from("Delete: remove support (no refund)").yellow() + } else if states.skirmish.can_build_support(coords, Players::Player) { + Line::from("Enter: build support").green() + } else { + Line::from("Support unavailable: site, builder or resources").red() + }, + Line::from("WARNING: removal can cause fatal collapses").red(), + ]; + if let Structures::Tunnel(tunnel) = structure + && let Some(work) = tunnel.support_work() + { + lines.push( + Line::from(format!( + "{}: {}%", + if work.removing { + "Removing support" + } else { + "Building support" + }, + tunnel.support_progress().unwrap_or(0) + )) + .cyan(), + ); + if board.support_worker(coords, work.owner).is_none() { + lines.push(Line::from("Paused: friendly builder required").yellow()); + } + } + } Clear.render(popup, buf); let block: Block<'_> = Block::bordered() .title(Line::from("Buildings").magenta().bold()) diff --git a/src/app/widgets/board.rs b/src/app/widgets/board.rs index 3df79f2..4157926 100644 --- a/src/app/widgets/board.rs +++ b/src/app/widgets/board.rs @@ -9,11 +9,12 @@ use std::{collections::HashSet, rc::Rc}; pub struct BoardWidget<'a> { state: &'a BoardState, + stress: bool, } impl<'a> BoardWidget<'a> { - pub fn new(state: &'a BoardState) -> Self { - Self { state } + pub fn new(state: &'a BoardState, stress: bool) -> Self { + Self { state, stress } } } @@ -60,7 +61,11 @@ impl Widget for BoardWidget<'_> { if queued_path.contains(&coords) { cell.set_marked(true); } - cell.render(*cell_area, buf); + if self.stress { + cell.render_stress(*cell_area, buf); + } else { + cell.render(*cell_area, buf); + } }); } } diff --git a/src/app/widgets/cell.rs b/src/app/widgets/cell.rs index 5a56789..9fe8c2f 100644 --- a/src/app/widgets/cell.rs +++ b/src/app/widgets/cell.rs @@ -24,6 +24,41 @@ pub struct CellWidget { } impl CellWidget { + pub fn render_stress(&self, area: Rect, buf: &mut Buffer) -> () { + if matches!(self.structure, Structures::Stone(_)) { + self.clone() + .set_marked(self.selected && self.marked) + .clone() + .render(area, buf); + return; + } + let stress: u8 = self.structure.get_stress(); + let red: u8 = (u16::from(stress) * 255 / 100) as u8; + let color: Color = if self.selected { + self.fg_color() + } else { + Color::Rgb(red, 255 - red, 30) + }; + let block: Block<'_> = Block::bordered().border_type(BorderType::Rounded).fg(color); + let inner: Rect = block.inner(area); + block.render(area, buf); + if inner.height == 0 || inner.width == 0 { + return; + } + let text: String = if stress == 100 && inner.width < 4 { + "100".to_string() + } else { + format!("{stress}%") + }; + Paragraph::new(text) + .fg(color) + .alignment(Alignment::Center) + .render( + Rect::new(inner.x, inner.y + inner.height / 2, inner.width, 1), + buf, + ); + } + pub fn new( row: usize, col: usize, @@ -124,12 +159,14 @@ impl CellWidget { .to_string() .fg(self.unit.get_owner().get_color()), ); - if let Structures::Stone(stone) = self.structure - && stone.get_durability() < stone.get_max_durability() + if matches!( + self.structure, + Structures::Stone(_) | Structures::CollapsedTunnel(_) + ) && self.structure.get_durability() < self.structure.get_max_durability() { - let percent: u32 = - u32::from(stone.get_durability()) * 100 / u32::from(stone.get_max_durability()); - let middle = lines.len() / 2; + let percent: u32 = u32::from(self.structure.get_durability()) * 100 + / u32::from(self.structure.get_max_durability()); + let middle: usize = lines.len() / 2; lines[middle] = Line::from(format!("{percent}%")).cyan(); } if let Structures::MineConstruction(site) = self.structure { @@ -155,6 +192,12 @@ impl CellWidget { }; lines[middle] = Line::from(label).cyan(); } + if let Structures::Tunnel(tunnel) = self.structure + && let Some(percent) = tunnel.support_progress() + { + let middle: usize = lines.len() / 2; + lines[middle] = Line::from(format!("{percent}%")).cyan(); + } lines } diff --git a/tests/context_keybindings.rs b/tests/context_keybindings.rs index 0adc95b..5659850 100644 --- a/tests/context_keybindings.rs +++ b/tests/context_keybindings.rs @@ -77,9 +77,16 @@ 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::Left, Action::Right, Action::Quit2] + [ + Action::Building, + Action::Left, + Action::Right, + Action::Stress, + Action::Quit2 + ] ); press(&mut app, KeyCode::Left, KeyModifiers::NONE); + press(&mut app, KeyCode::Right, KeyModifiers::NONE); press(&mut app, KeyCode::Enter, KeyModifiers::NONE); press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); assert_eq!(app.states_ref().unwrap().skirmish.board, before); @@ -218,12 +225,17 @@ fn popup_tabs_cycle_without_moving_map_or_changing_queues() -> () { 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] { + for (code, index) in [ + (KeyCode::Left, 1), + (KeyCode::Right, 0), + (KeyCode::Right, 1), + (KeyCode::Left, 0), + ] { press(&mut app, code, KeyModifiers::NONE); let state: &war_in_tunnels::app::states::SkirmishState = &app.states_ref().unwrap().skirmish; assert_eq!(state.board, before); - assert_eq!(state.selected_building, 0); + assert_eq!(state.selected_building, if key == 'b' { index } else { 0 }); assert_eq!(state.selected_recruit, 0); assert!(available_skirmish_actions(state).contains(&Action::Left)); assert!(available_skirmish_actions(state).contains(&Action::Right)); diff --git a/tests/digging.rs b/tests/digging.rs index 722fecd..4562140 100644 --- a/tests/digging.rs +++ b/tests/digging.rs @@ -362,6 +362,9 @@ fn confirmed_route_splits_into_moving_and_digging_segments() { for col in [3, 4, 7] { tunnel(&mut game.board, 5, col); } + let mut supported: Tunnel = Tunnel::new(); + supported.set_support(true); + game.board.cells[5][7].set_structure(Structures::Tunnel(supported)); game.board.change_focused_cell(MoveFocusedCell::Left); game.board.toggle_marking(); for _ in 0..5 { diff --git a/tests/tunnel_stress.rs b/tests/tunnel_stress.rs new file mode 100644 index 0000000..ddba93d --- /dev/null +++ b/tests/tunnel_stress.rs @@ -0,0 +1,610 @@ +use clap::Parser; +use ratatui::{ + Terminal, + backend::TestBackend, + buffer::{Buffer, Cell}, + crossterm::event::{KeyCode, KeyEvent, KeyModifiers}, + layout::Rect, + style::Color, + widgets::Widget, +}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use war_in_tunnels::{ + app::{ + Action, App, GameStates, View, available_skirmish_actions, handle_keybindings, + states::{ + SkirmishState, + skirmish_states::{ + BoardState, CellSizes, Players, Resources, ZoomLevel, + structures::{CollapsedTunnel, Structure, Structures, Tunnel}, + tasks::{DiggingTask, MovingTask, Task, Tasks}, + units::{MinerUnit, Unit, Units}, + }, + }, + threads::AudioCmd, + }, + cli::Cli, +}; + +fn app() -> App { + let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap(); + let (tx, _rx): (Sender, Receiver) = channel(); + let mut app: App = App::new(args, tx); + app.view = View::Skirmish; + app.window_area = Rect::new(0, 0, 160, 50); + app.states = Some(GameStates::new(&app.args, &app.window_area)); + app +} + +fn put_tunnel(board: &mut BoardState, coords: (usize, usize), supported: bool) -> () { + let mut tunnel: Tunnel = Tunnel::new(); + tunnel.set_support(supported); + board.cells[coords.0][coords.1].set_structure(Structures::Tunnel(tunnel)); +} + +fn miner(board: &mut BoardState, coords: (usize, usize), owner: Players) -> () { + *board.cells[coords.0][coords.1].get_mut_option_unit() = + Some(Units::Miner(MinerUnit::new(owner, coords))); +} + +fn press(app: &mut App, code: KeyCode) -> () { + handle_keybindings(app, KeyEvent::new(code, KeyModifiers::NONE)); +} + +fn finish_support_work(game: &mut SkirmishState) -> () { + for _ in 0..Tunnel::SUPPORT_WORK_TICKS { + game.advance_support_work(); + } +} + +#[test] +fn support_work_takes_three_seconds_freezes_worker_and_pauses_without_builder() -> () { + let mut app: App = app(); + let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; + put_tunnel(&mut game.board, (5, 3), false); + game.board.cells[5][2] + .get_mut_option_unit() + .set_task(Tasks::Moving(MovingTask::new([(5, 3)].into()))); + assert!(game.build_support((5, 3), Players::Player)); + for _ in 1..Tunnel::SUPPORT_WORK_TICKS { + game.tick_update(); + } + assert_eq!(game.board.cells[5][3].get_structure().get_level(), ' '); + assert_eq!( + game.board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].get_progress(), + 0.0 + ); + game.tick_update(); + assert_eq!(game.board.cells[5][3].get_structure().get_level(), 'T'); + assert!(game.board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].get_progress() > 0.0); + assert!(game.remove_support((5, 3), Players::Player)); + for _ in 0..45 { + game.tick_update(); + } + game.board.cells[5][2].get_mut_option_unit().take(); + let paused: Structures = game.board.cells[5][3].get_structure(); + for _ in 0..20 { + game.tick_update(); + } + assert_eq!(game.board.cells[5][3].get_structure(), paused); + assert_eq!(paused.get_level(), 'T'); + miner(&mut game.board, (5, 2), Players::Player); + for _ in 45..Tunnel::SUPPORT_WORK_TICKS { + game.tick_update(); + } + assert_eq!(game.board.cells[5][3].get_structure().get_level(), ' '); +} + +#[test] +fn one_miner_cannot_work_on_two_supports_simultaneously() -> () { + let mut app: App = app(); + let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; + put_tunnel(&mut game.board, (5, 3), false); + put_tunnel(&mut game.board, (5, 4), false); + miner(&mut game.board, (5, 3), Players::Player); + assert!(game.build_support((5, 3), Players::Player)); + assert!(game.build_support((5, 4), Players::Player)); + finish_support_work(game); + assert_eq!(game.board.cells[5][3].get_structure().get_level(), 'T'); + let Structures::Tunnel(tunnel) = game.board.cells[5][4].get_structure() else { + panic!("tunnel missing"); + }; + assert_eq!(tunnel.support_progress(), Some(0)); + finish_support_work(game); + assert_eq!(game.board.cells[5][4].get_structure().get_level(), 'T'); +} + +#[test] +fn stress_map_renders_stone_normally_at_every_zoom() -> () { + let app: App = app(); + for zoom in [ + ZoomLevel::Default, + ZoomLevel::ZoomedIn, + ZoomLevel::ZoomedOut, + ] { + for (selected, marked) in [(false, false), (false, true), (true, false), (true, true)] { + let area: Rect = Rect::new( + 0, + 0, + zoom.get_cell_size(CellSizes::Width) as u16, + zoom.get_cell_size(CellSizes::Height) as u16, + ); + let mut normal: Buffer = Buffer::empty(area); + let mut stress: Buffer = Buffer::empty(area); + app.states_ref().unwrap().skirmish.board.cells[5][4] + .clone() + .set_zoom_level(zoom) + .set_selected(selected) + .set_marked(selected && marked) + .clone() + .render(area, &mut normal); + app.states_ref().unwrap().skirmish.board.cells[5][4] + .clone() + .set_zoom_level(zoom) + .set_selected(selected) + .set_marked(marked) + .render_stress(area, &mut stress); + assert_eq!(normal, stress); + assert!( + !stress + .content + .iter() + .any(|cell: &Cell| -> bool { cell.symbol() == "%" }) + ); + } + } +} + +#[test] +fn task_key_requires_space_selection_and_moving_choice_resets_invalid_draft() -> () { + let mut app: App = app(); + press(&mut app, KeyCode::Left); + assert!( + !available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Tasks) + ); + press(&mut app, KeyCode::Char('t')); + assert!(!app.states_ref().unwrap().skirmish.task_view); + press(&mut app, KeyCode::Char(' ')); + assert!( + available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Tasks) + ); + press(&mut app, KeyCode::Right); + press(&mut app, KeyCode::Char('t')); + assert!(app.states_ref().unwrap().skirmish.task_view); + press(&mut app, KeyCode::Right); + press(&mut app, KeyCode::Enter); + let board: &BoardState = &app.states_ref().unwrap().skirmish.board; + assert!(board.marked_cells.marking_cells && board.movement_only); + assert_eq!(board.get_focused_cell().get_coords(), (5, 2)); + assert_eq!(board.get_marked_cells().len(), 1); + assert!( + board.cells[5][2] + .get_ref_option_unit() + .get_ref_tasks() + .is_empty() + ); + press(&mut app, KeyCode::Esc); + assert!( + !available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Tasks) + ); +} + +#[test] +fn stress_follows_cardinal_distance_from_base_and_caps_at_100() -> () { + let mut app: App = app(); + let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board; + for col in 3..=9 { + put_tunnel(board, (5, col), false); + } + put_tunnel(board, (4, 3), false); + put_tunnel(board, (6, 3), false); + board.recalculate_stress(); + for (col, stress) in [(3, 20), (4, 40), (5, 60), (6, 80), (7, 100), (9, 100)] { + assert_eq!(board.cells[5][col].get_structure().get_stress(), stress); + } + assert_eq!(board.cells[4][3].get_structure().get_stress(), 40); + assert_eq!(board.cells[6][3].get_structure().get_stress(), 40); +} + +#[test] +fn support_stabilizes_all_four_directions_and_has_level_marker() -> () { + let mut app: App = app(); + let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board; + put_tunnel(board, (5, 12), true); + for coords in [(4, 12), (6, 12), (5, 11), (5, 13)] { + put_tunnel(board, coords, false); + } + board.recalculate_stress(); + assert_eq!(board.cells[5][12].get_structure().get_level(), 'T'); + assert_eq!(board.cells[5][12].get_structure().get_stress(), 20); + for (row, col) in [(4, 12), (6, 12), (5, 11), (5, 13)] { + assert_eq!(board.cells[row][col].get_structure().get_stress(), 40); + } + assert_eq!(board.resolve_collapses(), 0); +} + +#[test] +fn removal_causes_fatal_cascade_and_preserves_safe_tunnels() -> () { + let mut app: App = app(); + let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; + for col in 3..=9 { + put_tunnel(&mut game.board, (5, col), col == 6); + } + miner(&mut game.board, (5, 6), Players::Player); + miner(&mut game.board, (5, 8), Players::Player); + miner(&mut game.board, (5, 9), Players::Enemy); + game.board.resolve_collapses(); + assert!(game.remove_support((5, 6), Players::Player)); + for _ in 1..Tunnel::SUPPORT_WORK_TICKS { + game.advance_support_work(); + } + assert_eq!(game.board.cells[5][6].get_structure().get_level(), 'T'); + assert!(game.board.cells[5][9].get_ref_option_unit().is_some()); + game.advance_support_work(); + for col in 7..=9 { + let structure: Structures = game.board.cells[5][col].get_structure(); + assert!(matches!(structure, Structures::CollapsedTunnel(_))); + assert_eq!(structure.get_durability(), 750); + assert!(game.board.cells[5][col].get_ref_option_unit().is_none()); + assert!( + !structure.can_enter( + game.board.cells[5][6] + .get_ref_option_unit() + .as_ref() + .unwrap() + ) + ); + } + assert!(game.board.cells[5][6].get_ref_option_unit().is_some()); + assert_eq!(game.board.cells[5][6].get_structure().get_stress(), 80); + assert_eq!(game.board.resolve_collapses(), 0); +} + +#[test] +fn alternate_support_prevents_collapse_after_removal() -> () { + let mut app: App = app(); + let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; + for col in 3..=10 { + put_tunnel(&mut game.board, (5, col), col == 6 || col == 9); + } + miner(&mut game.board, (5, 6), Players::Player); + assert!(game.remove_support((5, 6), Players::Player)); + finish_support_work(game); + assert!(matches!( + game.board.cells[5][8].get_structure(), + Structures::Tunnel(_) + )); + assert_eq!(game.board.cells[5][8].get_structure().get_stress(), 40); +} + +#[test] +fn supports_charge_atomically_require_local_builder_and_do_not_refund_removal() -> () { + let mut app: App = app(); + let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; + put_tunnel(&mut game.board, (5, 3), false); + put_tunnel(&mut game.board, (5, 15), false); + let before: Resources = game.player_resources; + assert!(!game.build_support((usize::MAX, 0), Players::Player)); + assert!(!game.build_support((5, 15), Players::Player)); + assert!(!game.build_support((5, 3), Players::Enemy)); + assert_eq!(game.player_resources, before); + game.player_resources = Resources::new(9, 100); + assert!(!game.build_support((5, 3), Players::Player)); + assert_eq!(game.player_resources, Resources::new(9, 100)); + game.player_resources = Resources::new(10, 2); + assert!(game.build_support((5, 3), Players::Player)); + assert_eq!(game.player_resources, Resources::new(0, 0)); + assert!(!game.build_support((5, 3), Players::Player)); + assert!(!game.remove_support((5, 3), Players::Player)); + finish_support_work(game); + assert!(game.remove_support((5, 3), Players::Player)); + assert_eq!(game.player_resources, Resources::new(0, 0)); + assert!(!game.remove_support((5, 3), Players::Player)); +} + +#[test] +fn support_building_tab_builds_and_removes_upgrade_using_local_miner() -> () { + let mut app: App = app(); + put_tunnel(&mut app.states_mut().unwrap().skirmish.board, (5, 3), false); + press(&mut app, KeyCode::Char('b')); + press(&mut app, KeyCode::Right); + assert_eq!(app.states_ref().unwrap().skirmish.selected_building, 1); + press(&mut app, KeyCode::Enter); + let game: &SkirmishState = &app.states_ref().unwrap().skirmish; + assert_eq!(game.board.cells[5][3].get_structure().get_level(), ' '); + finish_support_work(&mut app.states_mut().unwrap().skirmish); + let game: &SkirmishState = &app.states_ref().unwrap().skirmish; + assert_eq!(game.board.cells[5][3].get_structure().get_level(), 'T'); + assert_eq!(game.player_resources, Resources::new(40, 23)); + press(&mut app, KeyCode::Delete); + assert_eq!( + app.states_ref().unwrap().skirmish.board.cells[5][3] + .get_structure() + .get_level(), + 'T' + ); + finish_support_work(&mut app.states_mut().unwrap().skirmish); + assert_eq!( + app.states_ref().unwrap().skirmish.board.cells[5][3] + .get_structure() + .get_level(), + ' ' + ); + assert_eq!( + app.states_ref().unwrap().skirmish.player_resources, + Resources::new(40, 23) + ); + assert!(app.states_ref().unwrap().skirmish.building_view); +} + +#[test] +fn rubble_requires_less_work_than_stone_and_percent_is_rendered() -> () { + let mut app: App = app(); + let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; + game.board.cells[5][3].set_structure(Structures::CollapsedTunnel(CollapsedTunnel::new())); + game.board.cells[5][2] + .get_mut_option_unit() + .set_task(Tasks::Digging(DiggingTask::new([(5, 3)].into(), 25.0))); + game.tick_update(); + assert_eq!(game.board.cells[5][3].get_structure().get_durability(), 500); + let area: Rect = Rect::new(0, 0, 9, 5); + let mut buffer: Buffer = Buffer::empty(area); + game.board.cells[5][3].clone().render(area, &mut buffer); + let text: String = buffer + .content + .iter() + .map(|cell: &Cell| -> &str { cell.symbol() }) + .collect(); + assert!(text.contains("66%")); + for _ in 0..2 { + game.tick_update(); + } + assert!(game.board.cells[5][3].get_ref_option_unit().is_some()); + assert!(matches!( + game.board.cells[5][3].get_structure(), + Structures::Tunnel(_) + )); +} + +#[test] +fn automatic_supports_are_spaced_at_last_safe_tiles_and_do_not_waste_final_support() -> () { + let mut app: App = app(); + let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; + game.player_resources = Resources::new(1000, 1000); + game.board.cells[5][2] + .get_mut_option_unit() + .set_task(Tasks::Digging(DiggingTask::new( + (3..=12) + .map(|col: usize| -> (usize, usize) { (5, col) }) + .collect(), + 100.0, + ))); + for _ in 0..400 { + game.tick_update(); + if game.board.cells[5][12].get_ref_option_unit().is_some() { + break; + } + } + assert!(game.board.cells[5][12].get_ref_option_unit().is_some()); + assert_eq!(game.player_resources, Resources::new(985, 996)); + assert!(game.turn_counter >= u64::from(2 * Tunnel::SUPPORT_WORK_TICKS)); + for col in 3..=12 { + let Structures::Tunnel(tunnel) = game.board.cells[5][col].get_structure() else { + panic!("collapsed at {col}"); + }; + assert_eq!(tunnel.has_support(), col == 6 || col == 9); + assert!(tunnel.get_stress() < 100); + } +} + +#[test] +fn disabled_auto_supports_or_missing_funds_kills_miner_at_fifth_tile() -> () { + for (automatic, metal) in [(false, 100), (true, 0)] { + let mut app: App = app(); + let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; + game.player_resources = Resources::new(100, metal); + let mut task: DiggingTask = DiggingTask::new( + (3..=9) + .map(|col: usize| -> (usize, usize) { (5, col) }) + .collect(), + 100.0, + ); + task.set_auto_supports(automatic); + game.board.cells[5][2] + .get_mut_option_unit() + .set_task(Tasks::Digging(task)); + for _ in 0..5 { + game.tick_update(); + } + assert!(matches!( + game.board.cells[5][7].get_structure(), + Structures::CollapsedTunnel(_) + )); + assert!(game.board.cells[5][7].get_ref_option_unit().is_none()); + assert_eq!(game.player_resources, Resources::new(100, metal)); + } +} + +#[test] +fn rubble_can_be_dug_again_and_auto_setting_survives_task_conversion() -> () { + let mut app: App = app(); + let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; + game.board.cells[5][3].set_structure(Structures::CollapsedTunnel(CollapsedTunnel::new())); + let mut task: MovingTask = MovingTask::new([(5, 3)].into()); + task.set_auto_supports(false); + let dig: DiggingTask = DiggingTask::from_moving(task, 100.0); + assert!(!dig.auto_supports()); + assert!(!MovingTask::from_digging(dig.clone()).auto_supports()); + game.board.cells[5][2] + .get_mut_option_unit() + .set_task(Tasks::Digging(dig)); + game.tick_update(); + assert!(matches!( + game.board.cells[5][3].get_structure(), + Structures::Tunnel(_) + )); + assert!(game.board.cells[5][3].get_ref_option_unit().is_some()); +} + +#[test] +fn stress_view_hides_all_other_text_and_colors_but_keeps_focused_color() -> () { + let mut app: App = app(); + let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; + put_tunnel(&mut game.board, (5, 3), true); + miner(&mut game.board, (5, 3), Players::Player); + game.board.recalculate_stress(); + for zoom in [ + ZoomLevel::Default, + ZoomLevel::ZoomedIn, + ZoomLevel::ZoomedOut, + ] { + let area: Rect = Rect::new( + 0, + 0, + zoom.get_cell_size(CellSizes::Width) as u16, + zoom.get_cell_size(CellSizes::Height) as u16, + ); + let mut buffer: Buffer = Buffer::empty(area); + game.board.cells[5][3] + .clone() + .set_zoom_level(zoom) + .set_selected(false) + .set_marked(true) + .render_stress(area, &mut buffer); + let text: String = buffer + .content + .iter() + .map(|cell: &Cell| -> &str { cell.symbol() }) + .collect(); + assert!(text.contains("20%")); + assert!(!text.contains('M') && !text.contains('T')); + assert!(!buffer.content.iter().any(|cell: &Cell| -> bool { + matches!(cell.fg, Color::Magenta | Color::LightMagenta | Color::Blue) + })); + game.board.cells[5][3] + .clone() + .set_selected(true) + .render_stress(area, &mut buffer); + assert_eq!(buffer[(0, 0)].fg, Color::LightYellow); + game.board.cells[5][3] + .clone() + .set_selected(true) + .set_marked(true) + .render_stress(area, &mut buffer); + assert_eq!(buffer[(0, 0)].fg, Color::Magenta); + } +} + +#[test] +fn task_sidebar_selects_auto_supports_and_captures_setting_in_queued_task() -> () { + let mut app: App = app(); + press(&mut app, KeyCode::Left); + press(&mut app, KeyCode::Char(' ')); + let coords: (usize, usize) = app + .states_ref() + .unwrap() + .skirmish + .board + .get_focused_cell() + .get_coords(); + press(&mut app, KeyCode::Char('t')); + assert!(app.states_ref().unwrap().skirmish.task_view); + press(&mut app, KeyCode::Char('a')); + press(&mut app, KeyCode::Char('s')); + assert!(app.states_ref().unwrap().skirmish.stress_view); + assert!(!app.states_ref().unwrap().skirmish.board.auto_supports); + assert_eq!( + app.states_ref() + .unwrap() + .skirmish + .board + .get_focused_cell() + .get_coords(), + coords + ); + let mut terminal: Terminal = Terminal::new(TestBackend::new(160, 50)).unwrap(); + terminal + .draw(|frame: &mut ratatui::Frame<'_>| -> () { + frame.render_widget(&mut app, frame.area()); + }) + .unwrap(); + let text: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell: &Cell| -> &str { cell.symbol() }) + .collect(); + assert!(text.contains("Auto supports: OFF")); + assert!(text.contains("Unit tasks")); + let title: usize = terminal + .backend() + .buffer() + .content + .windows("Unit tasks".len()) + .position(|cells: &[Cell]| -> bool { + cells + .iter() + .map(|cell: &Cell| -> &str { cell.symbol() }) + .collect::() + == "Unit tasks" + }) + .unwrap(); + assert!(title % 160 > 100); + press(&mut app, KeyCode::Esc); + assert!(app.states_ref().unwrap().skirmish.task_view); + press(&mut app, KeyCode::Enter); + assert!(!app.states_ref().unwrap().skirmish.task_view); + assert!( + app.states_ref() + .unwrap() + .skirmish + .board + .marked_cells + .marking_cells + ); + press(&mut app, KeyCode::Right); + press(&mut app, KeyCode::Char(' ')); + assert!( + !app.states_ref().unwrap().skirmish.board.cells[5][2] + .get_ref_option_unit() + .get_ref_tasks()[0] + .auto_supports() + ); +} + +#[test] +fn moving_choice_rejects_stone_and_never_digs_collapsed_route() -> () { + let mut app: App = app(); + put_tunnel(&mut app.states_mut().unwrap().skirmish.board, (5, 3), false); + press(&mut app, KeyCode::Left); + press(&mut app, KeyCode::Char(' ')); + press(&mut app, KeyCode::Char('t')); + press(&mut app, KeyCode::Right); + assert_eq!(app.states_ref().unwrap().skirmish.selected_task, 1); + press(&mut app, KeyCode::Enter); + press(&mut app, KeyCode::Right); + press(&mut app, KeyCode::Right); + assert_eq!( + app.states_ref() + .unwrap() + .skirmish + .board + .get_focused_cell() + .get_coords(), + (5, 3) + ); + press(&mut app, KeyCode::Char(' ')); + let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; + let Tasks::Moving(task) = &game.board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0] + else { + panic!("not moving"); + }; + assert!(!task.dig_if_blocked); + game.board.cells[5][3].set_structure(Structures::CollapsedTunnel(CollapsedTunnel::new())); + for _ in 0..200 { + game.tick_update(); + } + assert_eq!(game.board.cells[5][3].get_structure().get_durability(), 750); + assert!(game.board.cells[5][2].get_ref_option_unit().is_some()); +}