generated from GarandPLG/rust-flake-template
Add support structures and stress mechanics
Introduce support structures (roof supports) and a stress system that propagates from bases through tunnels, causing collapses when stress reaches 100 %. Add auto‑support feature with toggleable tasks, new keybindings (Tasks, AutoSupports, Stress), and a support‑building tab. Implement collapsed tunnels, support work progress, and related UI rendering (stress view, auto‑support indicators). Extend board resize logic, update game state structs, and adjust keybinding actions. Update documentation (README, AGENTS, CODE_MAP, PROGRESS, TESTING) to reflect new features. Add flake.nix dependencies (bash, gitea‑actions‑runner, docker). Add extensive tests for tunnel stress, support building, auto‑supports, and related mechanics.
This commit is contained in:
1 parent
110d4c411a
commit
d293f95174
28 files changed
+1622
-73
No files matched your search
@@ -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.
|
||||
|
||||
+6
-2
@@ -11,6 +11,8 @@ Instrukcje i notatki dla agentów przechowujemy w `docs/`. Ten plik jest punktem
|
||||
|
||||
## Narzędzia i weryfikacja
|
||||
|
||||
Flake uruchamiany z repozytorium Git pomija pliki nieśledzone, nawet gdy Cargo widzi je lokalnie. Przy błędach Nix „file not found for module” sprawdź git status i git ls-files. Nowe źródła i testy można uwidocznić przez git add -N -- <pliki> (intent-to-add, bez dodawania treści do stagingu ani tworzenia commita). Sama zmiana src = ./. w default.nix nie przywróci plików pominiętych na wejściu flake.
|
||||
|
||||
Nie wolno zakończyć zadania, jeżeli kompilator Rust zgłasza błędy uniemożliwiające kompilację. Przed zakończeniem uruchom `nix develop -c cargo check --all-targets`; jeśli wystąpią błędy kompilacji, usuń je i powtórz kontrolę aż do uzyskania sukcesu.
|
||||
|
||||
Wszystkie narzędzia Rust uruchamiaj przez `nix develop -c` z katalogu repozytorium:
|
||||
@@ -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`.
|
||||
+16
-4
@@ -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<Vec<CellWidget>>` łączy model i prezentację: komórka zawiera strukturę, opcjonalną jednostkę, pozycję i flagi UI. `BoardWidget` wybiera widoczne komórki przez offsety i layout; `CellWidget` odpowiada za ich tekst i styl. Panel boczny prezentuje dane zaznaczenia. Zoom wpływa na wielkość komórek i liczbę widocznych wierszy/kolumn; resize i przełączanie panelu zmieniają granice offsetów.
|
||||
|
||||
Mapa powstaje deterministycznie: kamień, baza gracza w `(map_height / 2, 2)`, baza przeciwnika w `(map_height / 2, map_width - 3)`, sąsiadujące 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ę
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -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"
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
+4
-4
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -13,11 +13,26 @@ use crate::app::{
|
||||
use ratatui::{crossterm::event::KeyEvent, layout::Rect};
|
||||
|
||||
pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
|
||||
if state.task_view {
|
||||
let mut actions: Vec<Action> = vec![
|
||||
Action::Tasks,
|
||||
Action::Left,
|
||||
Action::Right,
|
||||
Action::Enter,
|
||||
Action::Stress,
|
||||
Action::Quit2,
|
||||
];
|
||||
if state.selected_task == 0 {
|
||||
actions.push(Action::AutoSupports);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
if state.recruitment_view {
|
||||
let mut actions: Vec<Action> = vec![
|
||||
Action::Recruitment,
|
||||
Action::Left,
|
||||
Action::Right,
|
||||
Action::Stress,
|
||||
Action::Quit2,
|
||||
];
|
||||
if state.can_recruit_miner(state.board.get_focused_cell().get_coords(), Players::Player) {
|
||||
@@ -26,9 +41,25 @@ pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
|
||||
return actions;
|
||||
}
|
||||
if state.building_view {
|
||||
let mut actions: Vec<Action> =
|
||||
vec![Action::Building, Action::Left, Action::Right, Action::Quit2];
|
||||
let mut actions: Vec<Action> = vec![
|
||||
Action::Building,
|
||||
Action::Left,
|
||||
Action::Right,
|
||||
Action::Stress,
|
||||
Action::Quit2,
|
||||
];
|
||||
let coords: (usize, usize) = state.board.get_focused_cell().get_coords();
|
||||
if state.selected_building == 1 {
|
||||
if state.can_build_support(coords, Players::Player) {
|
||||
actions.push(Action::Enter);
|
||||
}
|
||||
if state.can_manage_support(coords, Players::Player)
|
||||
&& matches!(state.board.cells[coords.0][coords.1].get_structure(), Structures::Tunnel(tunnel) if tunnel.has_support() && tunnel.support_work().is_none())
|
||||
{
|
||||
actions.push(Action::Delete);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
if let Structures::MineConstruction(site) =
|
||||
state.board.get_ref_cell(coords.0, coords.1).get_structure()
|
||||
&& site.get_owner() == Players::Player
|
||||
@@ -48,7 +79,12 @@ pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
|
||||
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> {
|
||||
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<Action> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+161
-6
@@ -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<CellWidget>| -> Option<&CellWidget> { row.get(coords.1) })
|
||||
.is_some_and(|cell: &CellWidget| -> bool {
|
||||
matches!(cell.get_structure(), Structures::Tunnel(_))
|
||||
&& ((cell.get_ref_option_unit().get_owner() == owner
|
||||
&& cell.get_ref_option_unit().get_can_build())
|
||||
|| self.board.has_adjacent_builder(coords, owner))
|
||||
})
|
||||
}
|
||||
|
||||
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<Excavation> = self.board.advance_turn_with_events();
|
||||
if !excavated.is_empty() {
|
||||
self.board.resolve_collapses();
|
||||
}
|
||||
self.board.advance_recruitment();
|
||||
self.board.advance_construction();
|
||||
|
||||
|
||||
@@ -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<Tasks> = path
|
||||
.chunk_by(|a, b| self.is_walkable(*a) == self.is_walkable(*b))
|
||||
.map(|segment| {
|
||||
let path = segment.iter().copied().collect();
|
||||
if self.is_walkable(segment[0]) {
|
||||
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<Excavation> = self.advance_turn_with_events();
|
||||
}
|
||||
|
||||
pub fn advance_turn_with_events(&mut self) -> Vec<Excavation> {
|
||||
let mut excavated: Vec<Excavation> = 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use super::{
|
||||
BoardState, Players,
|
||||
structures::{CollapsedTunnel, Structure, Structures},
|
||||
units::{Unit, Units},
|
||||
};
|
||||
use crate::app::widgets::CellWidget;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
impl BoardState {
|
||||
fn stress_distances(&self) -> Vec<Vec<u8>> {
|
||||
let mut distance: Vec<Vec<u8>> = vec![vec![100; self.map_width]; self.map_height];
|
||||
let mut queue: VecDeque<(usize, usize)> = VecDeque::new();
|
||||
for (row, cells) in self.cells.iter().enumerate() {
|
||||
for (col, cell) in cells.iter().enumerate() {
|
||||
let source: Option<u8> = match cell.get_structure() {
|
||||
Structures::Base(_) => Some(0),
|
||||
Structures::Tunnel(tunnel) if tunnel.has_support() => Some(20),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(stress) = source {
|
||||
distance[row][col] = stress;
|
||||
queue.push_back((row, col));
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Some((row, col)) = queue.pop_front() {
|
||||
let next: u8 = distance[row][col].saturating_add(20).min(100);
|
||||
for (r, c) in [
|
||||
(row.wrapping_sub(1), col),
|
||||
(row + 1, col),
|
||||
(row, col.wrapping_sub(1)),
|
||||
(row, col + 1),
|
||||
] {
|
||||
if r < self.map_height
|
||||
&& c < self.map_width
|
||||
&& next < distance[r][c]
|
||||
&& matches!(
|
||||
self.cells[r][c].get_structure(),
|
||||
Structures::Tunnel(_) | Structures::Base(_) | Structures::Mine(_)
|
||||
)
|
||||
{
|
||||
distance[r][c] = next;
|
||||
queue.push_back((r, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
distance
|
||||
}
|
||||
|
||||
pub fn prospective_tunnel_stress(&self, (row, col): (usize, usize)) -> u8 {
|
||||
let distance: Vec<Vec<u8>> = self.stress_distances();
|
||||
[
|
||||
(row.wrapping_sub(1), col),
|
||||
(row + 1, col),
|
||||
(row, col.wrapping_sub(1)),
|
||||
(row, col + 1),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(r, c): (usize, usize)| -> Option<u8> {
|
||||
distance
|
||||
.get(r)
|
||||
.and_then(|line: &Vec<u8>| -> Option<&u8> { line.get(c) })
|
||||
.copied()
|
||||
})
|
||||
.min()
|
||||
.unwrap_or(100)
|
||||
.saturating_add(20)
|
||||
.min(100)
|
||||
}
|
||||
|
||||
pub fn recalculate_stress(&mut self) -> () {
|
||||
let distance: Vec<Vec<u8>> = self.stress_distances();
|
||||
for (row, cells) in self.cells.iter_mut().enumerate() {
|
||||
for (col, cell) in cells.iter_mut().enumerate() {
|
||||
if let Structures::Tunnel(mut tunnel) = cell.get_structure() {
|
||||
tunnel.set_stress(distance[row][col]);
|
||||
cell.set_structure(Structures::Tunnel(tunnel));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn support_worker(
|
||||
&self,
|
||||
(row, col): (usize, usize),
|
||||
owner: Players,
|
||||
) -> Option<(usize, usize)> {
|
||||
if owner == Players::Unclaimed {
|
||||
return None;
|
||||
}
|
||||
[
|
||||
(row, col),
|
||||
(row.wrapping_sub(1), col),
|
||||
(row + 1, col),
|
||||
(row, col.wrapping_sub(1)),
|
||||
(row, col + 1),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|&(r, c): &(usize, usize)| -> bool {
|
||||
self.cells
|
||||
.get(r)
|
||||
.and_then(|line: &Vec<CellWidget>| -> Option<&CellWidget> { line.get(c) })
|
||||
.and_then(|cell: &CellWidget| -> Option<&Units> {
|
||||
cell.get_ref_option_unit().as_ref()
|
||||
})
|
||||
.is_some_and(|unit: &Units| -> bool {
|
||||
unit.get_owner() == owner && unit.get_can_build()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn working_on_support(&self, (row, col): (usize, usize)) -> bool {
|
||||
[
|
||||
(row, col),
|
||||
(row.wrapping_sub(1), col),
|
||||
(row + 1, col),
|
||||
(row, col.wrapping_sub(1)),
|
||||
(row, col + 1),
|
||||
]
|
||||
.into_iter()
|
||||
.any(|(r, c): (usize, usize)| -> bool {
|
||||
self.cells
|
||||
.get(r)
|
||||
.and_then(|line: &Vec<CellWidget>| -> Option<&CellWidget> { line.get(c) })
|
||||
.is_some_and(|cell: &CellWidget| -> bool {
|
||||
if let Structures::Tunnel(tunnel) = cell.get_structure()
|
||||
&& let Some(work) = tunnel.support_work()
|
||||
{
|
||||
self.support_worker((r, c), work.owner) == Some((row, col))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_collapses(&mut self) -> usize {
|
||||
let mut total: usize = 0;
|
||||
loop {
|
||||
self.recalculate_stress();
|
||||
let mut collapsed: usize = 0;
|
||||
for row in &mut self.cells {
|
||||
for cell in row {
|
||||
if matches!(cell.get_structure(), Structures::Tunnel(tunnel) if tunnel.get_stress() >= 100)
|
||||
{
|
||||
cell.set_structure(Structures::CollapsedTunnel(CollapsedTunnel::new()));
|
||||
cell.get_mut_option_unit().take();
|
||||
collapsed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
total += collapsed;
|
||||
if collapsed == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.marked_cells.marking_cells {
|
||||
let (row, col): (usize, usize) = self.marked_cells.selected_unit.get_coords();
|
||||
if self.cells[row][col].get_ref_option_unit().is_none() {
|
||||
self.marked_cells.marking_cells = false;
|
||||
self.end_marking_cells(true);
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SupportWork>,
|
||||
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<SupportWork> {
|
||||
self.support_work
|
||||
}
|
||||
|
||||
pub fn start_support_work(&mut self, owner: Players, removing: bool) -> bool {
|
||||
if owner == Players::Unclaimed
|
||||
|| self.support_work.is_some()
|
||||
|| self.roof_support != removing
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.support_work = Some(SupportWork {
|
||||
owner,
|
||||
removing,
|
||||
ticks: 0,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
pub fn support_progress(&self) -> Option<u16> {
|
||||
self.support_work
|
||||
.map(|work: SupportWork| -> u16 { work.ticks * 100 / Self::SUPPORT_WORK_TICKS })
|
||||
}
|
||||
|
||||
pub fn advance_support_work(&mut self) -> Option<bool> {
|
||||
let work: &mut SupportWork = self.support_work.as_mut()?;
|
||||
work.ticks += 1;
|
||||
if work.ticks < Self::SUPPORT_WORK_TICKS {
|
||||
return None;
|
||||
}
|
||||
let removing: bool = work.removing;
|
||||
self.roof_support = !removing;
|
||||
self.support_work = None;
|
||||
Some(removing)
|
||||
}
|
||||
|
||||
pub fn set_support(&mut self, supported: bool) -> () {
|
||||
self.roof_support = supported;
|
||||
}
|
||||
|
||||
pub fn set_stress(&mut self, stress: u8) -> () {
|
||||
self.stress = stress.min(100);
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
durability: 500,
|
||||
stress: 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<Line<'_>> {
|
||||
let mut lines: Vec<Line<'_>> = self.base_text();
|
||||
if let Some(work) = self.support_work {
|
||||
lines.push(
|
||||
Line::from(format!(
|
||||
"{} support: {}%",
|
||||
if work.removing {
|
||||
"Removing"
|
||||
} else {
|
||||
"Building"
|
||||
},
|
||||
self.support_progress().unwrap_or(0)
|
||||
))
|
||||
.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 {
|
||||
|
||||
@@ -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 {
|
||||
'⛏'
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
'→'
|
||||
}
|
||||
|
||||
@@ -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)>;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+131
-12
@@ -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<Line<'_>> = 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<Action> = 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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+48
-5
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<AudioCmd>, Receiver<AudioCmd>) = channel();
|
||||
let mut app: App = App::new(args, tx);
|
||||
app.view = View::Skirmish;
|
||||
app.window_area = Rect::new(0, 0, 160, 50);
|
||||
app.states = Some(GameStates::new(&app.args, &app.window_area));
|
||||
app
|
||||
}
|
||||
|
||||
fn put_tunnel(board: &mut BoardState, coords: (usize, usize), supported: bool) -> () {
|
||||
let mut tunnel: Tunnel = Tunnel::new();
|
||||
tunnel.set_support(supported);
|
||||
board.cells[coords.0][coords.1].set_structure(Structures::Tunnel(tunnel));
|
||||
}
|
||||
|
||||
fn miner(board: &mut BoardState, coords: (usize, usize), owner: Players) -> () {
|
||||
*board.cells[coords.0][coords.1].get_mut_option_unit() =
|
||||
Some(Units::Miner(MinerUnit::new(owner, coords)));
|
||||
}
|
||||
|
||||
fn press(app: &mut App, code: KeyCode) -> () {
|
||||
handle_keybindings(app, KeyEvent::new(code, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
fn finish_support_work(game: &mut SkirmishState) -> () {
|
||||
for _ in 0..Tunnel::SUPPORT_WORK_TICKS {
|
||||
game.advance_support_work();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn support_work_takes_three_seconds_freezes_worker_and_pauses_without_builder() -> () {
|
||||
let mut app: App = app();
|
||||
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
|
||||
put_tunnel(&mut game.board, (5, 3), false);
|
||||
game.board.cells[5][2]
|
||||
.get_mut_option_unit()
|
||||
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
|
||||
assert!(game.build_support((5, 3), Players::Player));
|
||||
for _ in 1..Tunnel::SUPPORT_WORK_TICKS {
|
||||
game.tick_update();
|
||||
}
|
||||
assert_eq!(game.board.cells[5][3].get_structure().get_level(), ' ');
|
||||
assert_eq!(
|
||||
game.board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].get_progress(),
|
||||
0.0
|
||||
);
|
||||
game.tick_update();
|
||||
assert_eq!(game.board.cells[5][3].get_structure().get_level(), 'T');
|
||||
assert!(game.board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].get_progress() > 0.0);
|
||||
assert!(game.remove_support((5, 3), Players::Player));
|
||||
for _ in 0..45 {
|
||||
game.tick_update();
|
||||
}
|
||||
game.board.cells[5][2].get_mut_option_unit().take();
|
||||
let paused: Structures = game.board.cells[5][3].get_structure();
|
||||
for _ in 0..20 {
|
||||
game.tick_update();
|
||||
}
|
||||
assert_eq!(game.board.cells[5][3].get_structure(), paused);
|
||||
assert_eq!(paused.get_level(), 'T');
|
||||
miner(&mut game.board, (5, 2), Players::Player);
|
||||
for _ in 45..Tunnel::SUPPORT_WORK_TICKS {
|
||||
game.tick_update();
|
||||
}
|
||||
assert_eq!(game.board.cells[5][3].get_structure().get_level(), ' ');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_miner_cannot_work_on_two_supports_simultaneously() -> () {
|
||||
let mut app: App = app();
|
||||
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
|
||||
put_tunnel(&mut game.board, (5, 3), false);
|
||||
put_tunnel(&mut game.board, (5, 4), false);
|
||||
miner(&mut game.board, (5, 3), Players::Player);
|
||||
assert!(game.build_support((5, 3), Players::Player));
|
||||
assert!(game.build_support((5, 4), Players::Player));
|
||||
finish_support_work(game);
|
||||
assert_eq!(game.board.cells[5][3].get_structure().get_level(), 'T');
|
||||
let Structures::Tunnel(tunnel) = game.board.cells[5][4].get_structure() else {
|
||||
panic!("tunnel missing");
|
||||
};
|
||||
assert_eq!(tunnel.support_progress(), Some(0));
|
||||
finish_support_work(game);
|
||||
assert_eq!(game.board.cells[5][4].get_structure().get_level(), 'T');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_map_renders_stone_normally_at_every_zoom() -> () {
|
||||
let app: App = app();
|
||||
for zoom in [
|
||||
ZoomLevel::Default,
|
||||
ZoomLevel::ZoomedIn,
|
||||
ZoomLevel::ZoomedOut,
|
||||
] {
|
||||
for (selected, marked) in [(false, false), (false, true), (true, false), (true, true)] {
|
||||
let area: Rect = Rect::new(
|
||||
0,
|
||||
0,
|
||||
zoom.get_cell_size(CellSizes::Width) as u16,
|
||||
zoom.get_cell_size(CellSizes::Height) as u16,
|
||||
);
|
||||
let mut normal: Buffer = Buffer::empty(area);
|
||||
let mut stress: Buffer = Buffer::empty(area);
|
||||
app.states_ref().unwrap().skirmish.board.cells[5][4]
|
||||
.clone()
|
||||
.set_zoom_level(zoom)
|
||||
.set_selected(selected)
|
||||
.set_marked(selected && marked)
|
||||
.clone()
|
||||
.render(area, &mut normal);
|
||||
app.states_ref().unwrap().skirmish.board.cells[5][4]
|
||||
.clone()
|
||||
.set_zoom_level(zoom)
|
||||
.set_selected(selected)
|
||||
.set_marked(marked)
|
||||
.render_stress(area, &mut stress);
|
||||
assert_eq!(normal, stress);
|
||||
assert!(
|
||||
!stress
|
||||
.content
|
||||
.iter()
|
||||
.any(|cell: &Cell| -> bool { cell.symbol() == "%" })
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_key_requires_space_selection_and_moving_choice_resets_invalid_draft() -> () {
|
||||
let mut app: App = app();
|
||||
press(&mut app, KeyCode::Left);
|
||||
assert!(
|
||||
!available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Tasks)
|
||||
);
|
||||
press(&mut app, KeyCode::Char('t'));
|
||||
assert!(!app.states_ref().unwrap().skirmish.task_view);
|
||||
press(&mut app, KeyCode::Char(' '));
|
||||
assert!(
|
||||
available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Tasks)
|
||||
);
|
||||
press(&mut app, KeyCode::Right);
|
||||
press(&mut app, KeyCode::Char('t'));
|
||||
assert!(app.states_ref().unwrap().skirmish.task_view);
|
||||
press(&mut app, KeyCode::Right);
|
||||
press(&mut app, KeyCode::Enter);
|
||||
let board: &BoardState = &app.states_ref().unwrap().skirmish.board;
|
||||
assert!(board.marked_cells.marking_cells && board.movement_only);
|
||||
assert_eq!(board.get_focused_cell().get_coords(), (5, 2));
|
||||
assert_eq!(board.get_marked_cells().len(), 1);
|
||||
assert!(
|
||||
board.cells[5][2]
|
||||
.get_ref_option_unit()
|
||||
.get_ref_tasks()
|
||||
.is_empty()
|
||||
);
|
||||
press(&mut app, KeyCode::Esc);
|
||||
assert!(
|
||||
!available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Tasks)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_follows_cardinal_distance_from_base_and_caps_at_100() -> () {
|
||||
let mut app: App = app();
|
||||
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
|
||||
for col in 3..=9 {
|
||||
put_tunnel(board, (5, col), false);
|
||||
}
|
||||
put_tunnel(board, (4, 3), false);
|
||||
put_tunnel(board, (6, 3), false);
|
||||
board.recalculate_stress();
|
||||
for (col, stress) in [(3, 20), (4, 40), (5, 60), (6, 80), (7, 100), (9, 100)] {
|
||||
assert_eq!(board.cells[5][col].get_structure().get_stress(), stress);
|
||||
}
|
||||
assert_eq!(board.cells[4][3].get_structure().get_stress(), 40);
|
||||
assert_eq!(board.cells[6][3].get_structure().get_stress(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn support_stabilizes_all_four_directions_and_has_level_marker() -> () {
|
||||
let mut app: App = app();
|
||||
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
|
||||
put_tunnel(board, (5, 12), true);
|
||||
for coords in [(4, 12), (6, 12), (5, 11), (5, 13)] {
|
||||
put_tunnel(board, coords, false);
|
||||
}
|
||||
board.recalculate_stress();
|
||||
assert_eq!(board.cells[5][12].get_structure().get_level(), 'T');
|
||||
assert_eq!(board.cells[5][12].get_structure().get_stress(), 20);
|
||||
for (row, col) in [(4, 12), (6, 12), (5, 11), (5, 13)] {
|
||||
assert_eq!(board.cells[row][col].get_structure().get_stress(), 40);
|
||||
}
|
||||
assert_eq!(board.resolve_collapses(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_causes_fatal_cascade_and_preserves_safe_tunnels() -> () {
|
||||
let mut app: App = app();
|
||||
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
|
||||
for col in 3..=9 {
|
||||
put_tunnel(&mut game.board, (5, col), col == 6);
|
||||
}
|
||||
miner(&mut game.board, (5, 6), Players::Player);
|
||||
miner(&mut game.board, (5, 8), Players::Player);
|
||||
miner(&mut game.board, (5, 9), Players::Enemy);
|
||||
game.board.resolve_collapses();
|
||||
assert!(game.remove_support((5, 6), Players::Player));
|
||||
for _ in 1..Tunnel::SUPPORT_WORK_TICKS {
|
||||
game.advance_support_work();
|
||||
}
|
||||
assert_eq!(game.board.cells[5][6].get_structure().get_level(), 'T');
|
||||
assert!(game.board.cells[5][9].get_ref_option_unit().is_some());
|
||||
game.advance_support_work();
|
||||
for col in 7..=9 {
|
||||
let structure: Structures = game.board.cells[5][col].get_structure();
|
||||
assert!(matches!(structure, Structures::CollapsedTunnel(_)));
|
||||
assert_eq!(structure.get_durability(), 750);
|
||||
assert!(game.board.cells[5][col].get_ref_option_unit().is_none());
|
||||
assert!(
|
||||
!structure.can_enter(
|
||||
game.board.cells[5][6]
|
||||
.get_ref_option_unit()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
)
|
||||
);
|
||||
}
|
||||
assert!(game.board.cells[5][6].get_ref_option_unit().is_some());
|
||||
assert_eq!(game.board.cells[5][6].get_structure().get_stress(), 80);
|
||||
assert_eq!(game.board.resolve_collapses(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_support_prevents_collapse_after_removal() -> () {
|
||||
let mut app: App = app();
|
||||
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
|
||||
for col in 3..=10 {
|
||||
put_tunnel(&mut game.board, (5, col), col == 6 || col == 9);
|
||||
}
|
||||
miner(&mut game.board, (5, 6), Players::Player);
|
||||
assert!(game.remove_support((5, 6), Players::Player));
|
||||
finish_support_work(game);
|
||||
assert!(matches!(
|
||||
game.board.cells[5][8].get_structure(),
|
||||
Structures::Tunnel(_)
|
||||
));
|
||||
assert_eq!(game.board.cells[5][8].get_structure().get_stress(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_charge_atomically_require_local_builder_and_do_not_refund_removal() -> () {
|
||||
let mut app: App = app();
|
||||
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
|
||||
put_tunnel(&mut game.board, (5, 3), false);
|
||||
put_tunnel(&mut game.board, (5, 15), false);
|
||||
let before: Resources = game.player_resources;
|
||||
assert!(!game.build_support((usize::MAX, 0), Players::Player));
|
||||
assert!(!game.build_support((5, 15), Players::Player));
|
||||
assert!(!game.build_support((5, 3), Players::Enemy));
|
||||
assert_eq!(game.player_resources, before);
|
||||
game.player_resources = Resources::new(9, 100);
|
||||
assert!(!game.build_support((5, 3), Players::Player));
|
||||
assert_eq!(game.player_resources, Resources::new(9, 100));
|
||||
game.player_resources = Resources::new(10, 2);
|
||||
assert!(game.build_support((5, 3), Players::Player));
|
||||
assert_eq!(game.player_resources, Resources::new(0, 0));
|
||||
assert!(!game.build_support((5, 3), Players::Player));
|
||||
assert!(!game.remove_support((5, 3), Players::Player));
|
||||
finish_support_work(game);
|
||||
assert!(game.remove_support((5, 3), Players::Player));
|
||||
assert_eq!(game.player_resources, Resources::new(0, 0));
|
||||
assert!(!game.remove_support((5, 3), Players::Player));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn support_building_tab_builds_and_removes_upgrade_using_local_miner() -> () {
|
||||
let mut app: App = app();
|
||||
put_tunnel(&mut app.states_mut().unwrap().skirmish.board, (5, 3), false);
|
||||
press(&mut app, KeyCode::Char('b'));
|
||||
press(&mut app, KeyCode::Right);
|
||||
assert_eq!(app.states_ref().unwrap().skirmish.selected_building, 1);
|
||||
press(&mut app, KeyCode::Enter);
|
||||
let game: &SkirmishState = &app.states_ref().unwrap().skirmish;
|
||||
assert_eq!(game.board.cells[5][3].get_structure().get_level(), ' ');
|
||||
finish_support_work(&mut app.states_mut().unwrap().skirmish);
|
||||
let game: &SkirmishState = &app.states_ref().unwrap().skirmish;
|
||||
assert_eq!(game.board.cells[5][3].get_structure().get_level(), 'T');
|
||||
assert_eq!(game.player_resources, Resources::new(40, 23));
|
||||
press(&mut app, KeyCode::Delete);
|
||||
assert_eq!(
|
||||
app.states_ref().unwrap().skirmish.board.cells[5][3]
|
||||
.get_structure()
|
||||
.get_level(),
|
||||
'T'
|
||||
);
|
||||
finish_support_work(&mut app.states_mut().unwrap().skirmish);
|
||||
assert_eq!(
|
||||
app.states_ref().unwrap().skirmish.board.cells[5][3]
|
||||
.get_structure()
|
||||
.get_level(),
|
||||
' '
|
||||
);
|
||||
assert_eq!(
|
||||
app.states_ref().unwrap().skirmish.player_resources,
|
||||
Resources::new(40, 23)
|
||||
);
|
||||
assert!(app.states_ref().unwrap().skirmish.building_view);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rubble_requires_less_work_than_stone_and_percent_is_rendered() -> () {
|
||||
let mut app: App = app();
|
||||
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
|
||||
game.board.cells[5][3].set_structure(Structures::CollapsedTunnel(CollapsedTunnel::new()));
|
||||
game.board.cells[5][2]
|
||||
.get_mut_option_unit()
|
||||
.set_task(Tasks::Digging(DiggingTask::new([(5, 3)].into(), 25.0)));
|
||||
game.tick_update();
|
||||
assert_eq!(game.board.cells[5][3].get_structure().get_durability(), 500);
|
||||
let area: Rect = Rect::new(0, 0, 9, 5);
|
||||
let mut buffer: Buffer = Buffer::empty(area);
|
||||
game.board.cells[5][3].clone().render(area, &mut buffer);
|
||||
let text: String = buffer
|
||||
.content
|
||||
.iter()
|
||||
.map(|cell: &Cell| -> &str { cell.symbol() })
|
||||
.collect();
|
||||
assert!(text.contains("66%"));
|
||||
for _ in 0..2 {
|
||||
game.tick_update();
|
||||
}
|
||||
assert!(game.board.cells[5][3].get_ref_option_unit().is_some());
|
||||
assert!(matches!(
|
||||
game.board.cells[5][3].get_structure(),
|
||||
Structures::Tunnel(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_supports_are_spaced_at_last_safe_tiles_and_do_not_waste_final_support() -> () {
|
||||
let mut app: App = app();
|
||||
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
|
||||
game.player_resources = Resources::new(1000, 1000);
|
||||
game.board.cells[5][2]
|
||||
.get_mut_option_unit()
|
||||
.set_task(Tasks::Digging(DiggingTask::new(
|
||||
(3..=12)
|
||||
.map(|col: usize| -> (usize, usize) { (5, col) })
|
||||
.collect(),
|
||||
100.0,
|
||||
)));
|
||||
for _ in 0..400 {
|
||||
game.tick_update();
|
||||
if game.board.cells[5][12].get_ref_option_unit().is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(game.board.cells[5][12].get_ref_option_unit().is_some());
|
||||
assert_eq!(game.player_resources, Resources::new(985, 996));
|
||||
assert!(game.turn_counter >= u64::from(2 * Tunnel::SUPPORT_WORK_TICKS));
|
||||
for col in 3..=12 {
|
||||
let Structures::Tunnel(tunnel) = game.board.cells[5][col].get_structure() else {
|
||||
panic!("collapsed at {col}");
|
||||
};
|
||||
assert_eq!(tunnel.has_support(), col == 6 || col == 9);
|
||||
assert!(tunnel.get_stress() < 100);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_auto_supports_or_missing_funds_kills_miner_at_fifth_tile() -> () {
|
||||
for (automatic, metal) in [(false, 100), (true, 0)] {
|
||||
let mut app: App = app();
|
||||
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
|
||||
game.player_resources = Resources::new(100, metal);
|
||||
let mut task: DiggingTask = DiggingTask::new(
|
||||
(3..=9)
|
||||
.map(|col: usize| -> (usize, usize) { (5, col) })
|
||||
.collect(),
|
||||
100.0,
|
||||
);
|
||||
task.set_auto_supports(automatic);
|
||||
game.board.cells[5][2]
|
||||
.get_mut_option_unit()
|
||||
.set_task(Tasks::Digging(task));
|
||||
for _ in 0..5 {
|
||||
game.tick_update();
|
||||
}
|
||||
assert!(matches!(
|
||||
game.board.cells[5][7].get_structure(),
|
||||
Structures::CollapsedTunnel(_)
|
||||
));
|
||||
assert!(game.board.cells[5][7].get_ref_option_unit().is_none());
|
||||
assert_eq!(game.player_resources, Resources::new(100, metal));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rubble_can_be_dug_again_and_auto_setting_survives_task_conversion() -> () {
|
||||
let mut app: App = app();
|
||||
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
|
||||
game.board.cells[5][3].set_structure(Structures::CollapsedTunnel(CollapsedTunnel::new()));
|
||||
let mut task: MovingTask = MovingTask::new([(5, 3)].into());
|
||||
task.set_auto_supports(false);
|
||||
let dig: DiggingTask = DiggingTask::from_moving(task, 100.0);
|
||||
assert!(!dig.auto_supports());
|
||||
assert!(!MovingTask::from_digging(dig.clone()).auto_supports());
|
||||
game.board.cells[5][2]
|
||||
.get_mut_option_unit()
|
||||
.set_task(Tasks::Digging(dig));
|
||||
game.tick_update();
|
||||
assert!(matches!(
|
||||
game.board.cells[5][3].get_structure(),
|
||||
Structures::Tunnel(_)
|
||||
));
|
||||
assert!(game.board.cells[5][3].get_ref_option_unit().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_view_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<TestBackend> = Terminal::new(TestBackend::new(160, 50)).unwrap();
|
||||
terminal
|
||||
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
|
||||
frame.render_widget(&mut app, frame.area());
|
||||
})
|
||||
.unwrap();
|
||||
let text: String = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.map(|cell: &Cell| -> &str { cell.symbol() })
|
||||
.collect();
|
||||
assert!(text.contains("Auto supports: OFF"));
|
||||
assert!(text.contains("Unit tasks"));
|
||||
let title: usize = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.windows("Unit tasks".len())
|
||||
.position(|cells: &[Cell]| -> bool {
|
||||
cells
|
||||
.iter()
|
||||
.map(|cell: &Cell| -> &str { cell.symbol() })
|
||||
.collect::<String>()
|
||||
== "Unit tasks"
|
||||
})
|
||||
.unwrap();
|
||||
assert!(title % 160 > 100);
|
||||
press(&mut app, KeyCode::Esc);
|
||||
assert!(app.states_ref().unwrap().skirmish.task_view);
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert!(!app.states_ref().unwrap().skirmish.task_view);
|
||||
assert!(
|
||||
app.states_ref()
|
||||
.unwrap()
|
||||
.skirmish
|
||||
.board
|
||||
.marked_cells
|
||||
.marking_cells
|
||||
);
|
||||
press(&mut app, KeyCode::Right);
|
||||
press(&mut app, KeyCode::Char(' '));
|
||||
assert!(
|
||||
!app.states_ref().unwrap().skirmish.board.cells[5][2]
|
||||
.get_ref_option_unit()
|
||||
.get_ref_tasks()[0]
|
||||
.auto_supports()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_choice_rejects_stone_and_never_digs_collapsed_route() -> () {
|
||||
let mut app: App = app();
|
||||
put_tunnel(&mut app.states_mut().unwrap().skirmish.board, (5, 3), false);
|
||||
press(&mut app, KeyCode::Left);
|
||||
press(&mut app, KeyCode::Char(' '));
|
||||
press(&mut app, KeyCode::Char('t'));
|
||||
press(&mut app, KeyCode::Right);
|
||||
assert_eq!(app.states_ref().unwrap().skirmish.selected_task, 1);
|
||||
press(&mut app, KeyCode::Enter);
|
||||
press(&mut app, KeyCode::Right);
|
||||
press(&mut app, KeyCode::Right);
|
||||
assert_eq!(
|
||||
app.states_ref()
|
||||
.unwrap()
|
||||
.skirmish
|
||||
.board
|
||||
.get_focused_cell()
|
||||
.get_coords(),
|
||||
(5, 3)
|
||||
);
|
||||
press(&mut app, KeyCode::Char(' '));
|
||||
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
|
||||
let Tasks::Moving(task) = &game.board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0]
|
||||
else {
|
||||
panic!("not moving");
|
||||
};
|
||||
assert!(!task.dig_if_blocked);
|
||||
game.board.cells[5][3].set_structure(Structures::CollapsedTunnel(CollapsedTunnel::new()));
|
||||
for _ in 0..200 {
|
||||
game.tick_update();
|
||||
}
|
||||
assert_eq!(game.board.cells[5][3].get_structure().get_durability(), 750);
|
||||
assert!(game.board.cells[5][2].get_ref_option_unit().is_some());
|
||||
}
|
||||
Reference in new issue
Block a user