Update formatting to use 4‑space indentation
Nix package / build (push) Successful in 8m44s
Linux package builds (test) / linux (push) Successful in 2m25s
Linux package builds (test) / aur (push) Successful in 7m41s
Rust checks / check (push) Successful in 3m39s

- Adjust rustfmt.toml to set tab_spaces = 4.
- Reformat imports, struct definitions, function bodies, and match arms
  across the project to match the new style (aligned fields, added
  spacing, etc.).
- Ensure all code complies with the updated rustfmt settings.
This commit is contained in:
GarandPLG committed 2026-10-19 17:43:43 +02:00
1 parent a981663152
commit 73f544d5d6
66 files changed
+5378 -5373

No files matched your search

+1 -1
View File
@@ -112,7 +112,7 @@ Zaktualizowano instrukcje agentów, mapę i opis testów. Nie zmieniano mechanik
## 2026-10-19 — formatowanie Rust z wcięciami po 2 spacje ## 2026-10-19 — formatowanie Rust z wcięciami po 2 spacje
Dodano `rustfmt.toml` z uzgodnionymi opcjami: edycja i styl 2024, `max_width = 100`, `hard_tabs = false`, `tab_spaces = 2`, zakończenia Unix, sortowanie importów i modułów oraz domyślne heurystyki krótkich konstrukcji. Zastosowano `nix develop -c cargo fmt --all` do źródeł i testów (56 zmienionych plików Rust). Zaktualizowano instrukcje agentów i mapę konfiguracji projektu. Dodano `rustfmt.toml` z uzgodnionymi opcjami: edycja i styl 2024, `max_width = 100`, `hard_tabs = false`, `tab_spaces = 4`, zakończenia Unix, sortowanie importów i modułów oraz domyślne heurystyki krótkich konstrukcji. Zastosowano `nix develop -c cargo fmt --all` do źródeł i testów (56 zmienionych plików Rust). Zaktualizowano instrukcje agentów i mapę konfiguracji projektu.
Weryfikacja przez `nix develop -c`: `cargo fmt --all -- --check`, `cargo check --all-targets` i `cargo test --all-targets` zakończone sukcesem; 45 testów zaliczonych. Nie wprowadzano zmian mechanik ani zależności. Nie powtarzano Clippy ani ręcznej sesji TUI przy zmianie formatowania. Weryfikacja przez `nix develop -c`: `cargo fmt --all -- --check`, `cargo check --all-targets` i `cargo test --all-targets` zakończone sukcesem; 45 testów zaliczonych. Nie wprowadzano zmian mechanik ani zależności. Nie powtarzano Clippy ani ręcznej sesji TUI przy zmianie formatowania.
+1 -1
View File
@@ -3,7 +3,7 @@ style_edition = "2024"
max_width = 100 max_width = 100
hard_tabs = false hard_tabs = false
tab_spaces = 2 tab_spaces = 4
newline_style = "Unix" newline_style = "Unix"
reorder_imports = true reorder_imports = true
+85 -84
View File
@@ -1,102 +1,103 @@
use crate::{ use crate::{
app::{GameStates, handle_keybindings, threads::AudioCmd, view::View}, app::{GameStates, handle_keybindings, threads::AudioCmd, view::View},
cli::Cli, cli::Cli,
}; };
use ratatui::{DefaultTerminal, Frame, crossterm::event::KeyEvent, layout::Rect}; use ratatui::{DefaultTerminal, Frame, crossterm::event::KeyEvent, layout::Rect};
use std::{ use std::{
io::Result, io::Result,
sync::mpsc::{Receiver, Sender}, sync::mpsc::{Receiver, Sender},
thread::sleep, thread::sleep,
time::Duration, time::Duration,
}; };
pub struct App { pub struct App {
pub exit: bool, pub exit: bool,
pub view: View, pub view: View,
pub window_area: Rect, pub window_area: Rect,
pub args: Cli, pub args: Cli,
pub states: Option<GameStates>, pub states: Option<GameStates>,
pub audio_tx: Sender<AudioCmd>, pub audio_tx: Sender<AudioCmd>,
} }
impl App { impl App {
pub fn new(args: Cli, audio_tx: Sender<AudioCmd>) -> Self { pub fn new(args: Cli, audio_tx: Sender<AudioCmd>) -> Self {
Self { Self {
exit: false, exit: false,
view: args.view, view: args.view,
window_area: Rect::default(), window_area: Rect::default(),
args: args, args: args,
states: None, states: None,
audio_tx, audio_tx,
}
}
pub fn states_ref(&self) -> Option<&GameStates> {
self.states.as_ref()
}
pub fn states_mut(&mut self) -> Option<&mut GameStates> {
self.states.as_mut()
}
pub fn run(
&mut self,
terminal: &mut DefaultTerminal,
input_rx: Receiver<KeyEvent>,
resize_rx: Receiver<(u16, u16)>,
tick_rx: Receiver<()>,
) -> Result<()> {
while !self.exit {
terminal.draw(|frame: &mut Frame<'_>| self.draw(frame))?;
if let Ok(key) = input_rx.try_recv() {
self.handle_key_event(key)?;
}
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);
} }
}
if let Ok(()) = tick_rx.try_recv() {
self.update()?;
}
sleep(Duration::from_millis(10));
} }
Ok(()) pub fn states_ref(&self) -> Option<&GameStates> {
} self.states.as_ref()
fn draw(&mut self, frame: &mut Frame<'_>) {
let window_area: Rect = frame.area();
if window_area.width != self.window_area.width || window_area.height != self.window_area.height
{
self.window_area = window_area;
if self.states.is_none() {
self.states = Some(GameStates::new(&self.args, &self.window_area))
}
} }
frame.render_widget(self, window_area); pub fn states_mut(&mut self) -> Option<&mut GameStates> {
} self.states.as_mut()
fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<()> {
handle_keybindings(self, key_event);
Ok(())
}
fn update(&mut self) -> Result<()> {
if let Some(state) = self.states_mut() {
state.skirmish.tick_update();
} }
Ok(()) pub fn run(
} &mut self,
terminal: &mut DefaultTerminal,
input_rx: Receiver<KeyEvent>,
resize_rx: Receiver<(u16, u16)>,
tick_rx: Receiver<()>,
) -> Result<()> {
while !self.exit {
terminal.draw(|frame: &mut Frame<'_>| self.draw(frame))?;
if let Ok(key) = input_rx.try_recv() {
self.handle_key_event(key)?;
}
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);
}
}
if let Ok(()) = tick_rx.try_recv() {
self.update()?;
}
sleep(Duration::from_millis(10));
}
Ok(())
}
fn draw(&mut self, frame: &mut Frame<'_>) {
let window_area: Rect = frame.area();
if window_area.width != self.window_area.width
|| window_area.height != self.window_area.height
{
self.window_area = window_area;
if self.states.is_none() {
self.states = Some(GameStates::new(&self.args, &self.window_area))
}
}
frame.render_widget(self, window_area);
}
fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<()> {
handle_keybindings(self, key_event);
Ok(())
}
fn update(&mut self) -> Result<()> {
if let Some(state) = self.states_mut() {
state.skirmish.tick_update();
}
Ok(())
}
} }
+17 -17
View File
@@ -1,6 +1,6 @@
use ratatui::{ use ratatui::{
style::{Color, Style, Stylize}, style::{Color, Style, Stylize},
text::{Line, Span}, text::{Line, Span},
}; };
/// Creates a styled title `Line` for a UI block. /// Creates a styled title `Line` for a UI block.
@@ -9,27 +9,27 @@ use ratatui::{
/// the spans, so the returned `Line` owns all its data and does not borrow from /// the spans, so the returned `Line` owns all its data and does not borrow from
/// the caller. /// the caller.
pub fn block_title_helper( pub fn block_title_helper(
texts: &[(String, Color)], texts: &[(String, Color)],
separator: Option<&'static str>, separator: Option<&'static str>,
) -> Line<'static> { ) -> Line<'static> {
let mut line: Line<'static> = Line::default(); let mut line: Line<'static> = Line::default();
line.spans.push(Span::from("[ ").gray()); line.spans.push(Span::from("[ ").gray());
for (i, (text, color)) in texts.iter().enumerate() { for (i, (text, color)) in texts.iter().enumerate() {
let span: Span<'_> = Span::styled(text.clone(), Style::new().fg(*color)); let span: Span<'_> = Span::styled(text.clone(), Style::new().fg(*color));
line.spans.push(span); line.spans.push(span);
if let Some(sep) = separator { if let Some(sep) = separator {
if i + 1 < texts.len() { if i + 1 < texts.len() {
line.spans.push(Span::from(sep).gray()); line.spans.push(Span::from(sep).gray());
} }
}
} }
}
line.spans.push(Span::from(" ]").gray()); line.spans.push(Span::from(" ]").gray());
line line
} }
/// Convenience wrapper for a single‑title line. /// Convenience wrapper for a single‑title line.
@@ -38,5 +38,5 @@ pub fn block_title_helper(
/// forwards it to `block_title_helper`. Because `block_title_helper` clones the /// forwards it to `block_title_helper`. Because `block_title_helper` clones the
/// string, the temporary slice is safe. /// string, the temporary slice is safe.
pub fn block_single_title_helper(text: String, color: Color) -> Line<'static> { pub fn block_single_title_helper(text: String, color: Color) -> Line<'static> {
block_title_helper(&[(text, color)], None) block_title_helper(&[(text, color)], None)
} }
+10 -10
View File
@@ -1,17 +1,17 @@
use crate::app::views::{skirmish_layout, skirmish_main_area_layout}; use crate::app::views::{skirmish_layout, skirmish_main_area_layout};
use ratatui::{ use ratatui::{
layout::{Margin, Rect}, layout::{Margin, Rect},
widgets::{Block, Borders}, widgets::{Block, Borders},
}; };
pub fn cells_area_helper(area: &Rect, side_panel: bool) -> Rect { pub fn cells_area_helper(area: &Rect, side_panel: bool) -> Rect {
let board_area: Rect = skirmish_main_area_layout(skirmish_layout(*area)[1], side_panel)[0]; let board_area: Rect = skirmish_main_area_layout(skirmish_layout(*area)[1], side_panel)[0];
Block::new() Block::new()
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT) .borders(Borders::LEFT | Borders::TOP | Borders::RIGHT)
.inner(board_area) .inner(board_area)
.inner(Margin { .inner(Margin {
horizontal: 1, horizontal: 1,
vertical: 1, vertical: 1,
}) })
} }
+11 -11
View File
@@ -1,13 +1,13 @@
pub fn main_menu_option_helper(s: String) -> String { pub fn main_menu_option_helper(s: String) -> String {
if let Some(pos) = s if let Some(pos) = s
.char_indices() .char_indices()
.filter(|(_, c)| c.is_ascii_uppercase()) .filter(|(_, c)| c.is_ascii_uppercase())
.nth(1) .nth(1)
.map(|(i, _)| i) .map(|(i, _)| i)
{ {
let (first, second) = s.split_at(pos); let (first, second) = s.split_at(pos);
format!("{first} {second}") format!("{first} {second}")
} else { } else {
s s
} }
} }
+8 -8
View File
@@ -1,14 +1,14 @@
use crate::app::{ use crate::app::{
App, App,
keybindings::{default_keybindings, main_menu_keybindings, skirmish_keybindings}, keybindings::{default_keybindings, main_menu_keybindings, skirmish_keybindings},
view::View, view::View,
}; };
use ratatui::crossterm::event::KeyEvent; use ratatui::crossterm::event::KeyEvent;
pub fn handle_keybindings(app: &mut App, key_event: KeyEvent) { pub fn handle_keybindings(app: &mut App, key_event: KeyEvent) {
match app.view { match app.view {
View::MainMenu => main_menu_keybindings(app, &key_event), View::MainMenu => main_menu_keybindings(app, &key_event),
View::Skirmish => skirmish_keybindings(app, &key_event), View::Skirmish => skirmish_keybindings(app, &key_event),
_ => default_keybindings(app, &key_event), _ => default_keybindings(app, &key_event),
} }
} }
+14 -14
View File
@@ -1,23 +1,23 @@
use crate::app::{ use crate::app::{
App, View, App, View,
keybindings::{Action, event_to_action}, keybindings::{Action, event_to_action},
threads::AudioCmd, threads::AudioCmd,
}; };
use ratatui::crossterm::event::KeyEvent; use ratatui::crossterm::event::KeyEvent;
pub fn common_keybindings(app: &mut App, action: Action) { pub fn common_keybindings(app: &mut App, action: Action) {
match action { match action {
Action::Quit | Action::Quit2 => app.exit = true, Action::Quit | Action::Quit2 => app.exit = true,
Action::Esc => app.view = View::MainMenu, Action::Esc => app.view = View::MainMenu,
Action::Mute => if let Err(_) = app.audio_tx.send(AudioCmd::Mute) {}, Action::Mute => if let Err(_) = app.audio_tx.send(AudioCmd::Mute) {},
Action::VolumeUp => if let Err(_) = app.audio_tx.send(AudioCmd::VolumeUp) {}, Action::VolumeUp => if let Err(_) = app.audio_tx.send(AudioCmd::VolumeUp) {},
Action::VolumeDown => if let Err(_) = app.audio_tx.send(AudioCmd::VolumeDown) {}, Action::VolumeDown => if let Err(_) = app.audio_tx.send(AudioCmd::VolumeDown) {},
_ => (), _ => (),
} }
} }
pub fn default_keybindings(app: &mut App, key_event: &KeyEvent) { pub fn default_keybindings(app: &mut App, key_event: &KeyEvent) {
if let Some(action) = event_to_action(&key_event) { if let Some(action) = event_to_action(&key_event) {
common_keybindings(app, action); common_keybindings(app, action);
} }
} }
+345 -345
View File
@@ -9,80 +9,80 @@ use std::collections::HashMap;
/// character key and carries the actual character pressed. /// character key and carries the actual character pressed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action { pub enum Action {
/// Request the application to quit. /// Request the application to quit.
Quit, Quit,
/// Alternate quit action. /// Alternate quit action.
Quit2, Quit2,
/// Move the selection cursor up. /// Move the selection cursor up.
Up, Up,
/// Move the selection cursor down. /// Move the selection cursor down.
Down, Down,
/// Move the selection cursor left. /// Move the selection cursor left.
Left, Left,
/// Move the selection cursor right. /// Move the selection cursor right.
Right, Right,
/// Scroll up without moving the cursor. /// Scroll up without moving the cursor.
ScrollUp, ScrollUp,
/// Scroll down without moving the cursor. /// Scroll down without moving the cursor.
ScrollDown, ScrollDown,
/// Scroll left without moving the cursor. /// Scroll left without moving the cursor.
ScrollLeft, ScrollLeft,
/// Scroll right without moving the cursor. /// Scroll right without moving the cursor.
ScrollRight, ScrollRight,
/// Toggle selecting cell. /// Toggle selecting cell.
Space, Space,
/// Open or close the construction view. /// Open or close the construction view.
Building, Building,
/// Open or close the recruitment view. /// Open or close the recruitment view.
Recruitment, Recruitment,
/// Unmark last selected cell. /// Unmark last selected cell.
Backspace, Backspace,
/// Cancel marking cell. /// Cancel marking cell.
Delete, Delete,
/// Submit or confirm the current choice. /// Submit or confirm the current choice.
Enter, Enter,
/// Return to the main menu or previous screen. /// Return to the main menu or previous screen.
Esc, Esc,
/// Zoom the view in. /// Zoom the view in.
ZoomIn, ZoomIn,
/// Zoom the view out. /// Zoom the view out.
ZoomOut, ZoomOut,
/// Open side panel. /// Open side panel.
Tab, Tab,
/// Toggle skirmish board/skills view. /// Toggle skirmish board/skills view.
ShiftTab, ShiftTab,
/// Mute music. /// Mute music.
Mute, Mute,
/// Volume up. /// Volume up.
VolumeUp, VolumeUp,
/// Volume down. /// Volume down.
VolumeDown, VolumeDown,
/// Matches any character key; the inner `char` is the actual key pressed. /// Matches any character key; the inner `char` is the actual key pressed.
WildCard(char), WildCard(char),
} }
/// Logical groups of key bindings used for organising help screens or /// Logical groups of key bindings used for organising help screens or
/// configuration sections. /// configuration sections.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
pub enum Group { pub enum Group {
/// Bindings that combine `Ctrl` with movement keys. /// Bindings that combine `Ctrl` with movement keys.
CtrlMovement, CtrlMovement,
/// Plain movement bindings. /// Plain movement bindings.
Movement, Movement,
/// Scroll bindings. /// Scroll bindings.
Scroll, Scroll,
/// Selection bindings. /// Selection bindings.
Select, Select,
/// Text input related bindings. /// Text input related bindings.
Input, Input,
/// Zoom related bindings. /// Zoom related bindings.
Zoom, Zoom,
/// Music related bindings. /// Music related bindings.
Music, Music,
/// Opens/Closes/Toggles elements in current view. /// Opens/Closes/Toggles elements in current view.
Opener, Opener,
/// Quit related bindings. /// Quit related bindings.
Quit, Quit,
} }
/// Description of a single key binding. /// Description of a single key binding.
@@ -91,20 +91,20 @@ pub enum Group {
/// together with metadata used for displaying help information. /// together with metadata used for displaying help information.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct KeyBinding { pub struct KeyBinding {
/// The logical action performed when this binding is triggered. /// The logical action performed when this binding is triggered.
pub action: Action, pub action: Action,
/// The raw `crossterm::event::KeyCode` that identifies the key. /// The raw `crossterm::event::KeyCode` that identifies the key.
pub code: KeyCode, pub code: KeyCode,
/// The kind of key event (crossterm::event::KeyEventKind). /// The kind of key event (crossterm::event::KeyEventKind).
pub kind: KeyEventKind, pub kind: KeyEventKind,
/// Any modifier keys that must be held (crossterm::event::KeyModifiers). /// Any modifier keys that must be held (crossterm::event::KeyModifiers).
pub modifiers: KeyModifiers, pub modifiers: KeyModifiers,
/// The group this binding belongs to, useful for categorising help. /// The group this binding belongs to, useful for categorising help.
pub group: Group, pub group: Group,
/// Human‑readable symbol shown in UI/help screens. /// Human‑readable symbol shown in UI/help screens.
pub symbol: &'static str, pub symbol: &'static str,
/// Short description of what the binding does. /// Short description of what the binding does.
pub description: &'static str, pub description: &'static str,
} }
/// Complete list of all key bindings used by the application. /// Complete list of all key bindings used by the application.
@@ -112,245 +112,245 @@ pub struct KeyBinding {
/// The slice is ordered primarily for readability; the runtime lookup scans /// The slice is ordered primarily for readability; the runtime lookup scans
/// this slice linearly, which is acceptable given the modest size of the list. /// this slice linearly, which is acceptable given the modest size of the list.
static KEYBINDINGS: &[KeyBinding] = &[ static KEYBINDINGS: &[KeyBinding] = &[
KeyBinding { KeyBinding {
action: Action::Recruitment, action: Action::Recruitment,
code: KeyCode::Char(' '), code: KeyCode::Char(' '),
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::ALT, modifiers: KeyModifiers::ALT,
group: Group::Opener, group: Group::Opener,
symbol: "Alt + Space / r", symbol: "Alt + Space / r",
description: "Recruit", description: "Recruit",
}, },
KeyBinding { KeyBinding {
action: Action::Recruitment, action: Action::Recruitment,
code: KeyCode::Char('r'), code: KeyCode::Char('r'),
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Opener, group: Group::Opener,
symbol: "r", symbol: "r",
description: "Recruit", description: "Recruit",
}, },
KeyBinding { KeyBinding {
action: Action::Building, action: Action::Building,
code: KeyCode::Char(' '), code: KeyCode::Char(' '),
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL, modifiers: KeyModifiers::CONTROL,
group: Group::Opener, group: Group::Opener,
symbol: "Ctrl + Space", symbol: "Ctrl + Space",
description: "Buildings", description: "Buildings",
}, },
KeyBinding { KeyBinding {
action: Action::Quit, action: Action::Quit,
code: KeyCode::Char('q'), code: KeyCode::Char('q'),
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Quit, group: Group::Quit,
symbol: "q", symbol: "q",
description: "Quit", description: "Quit",
}, },
KeyBinding { KeyBinding {
action: Action::Quit2, action: Action::Quit2,
code: KeyCode::Char('c'), code: KeyCode::Char('c'),
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL, modifiers: KeyModifiers::CONTROL,
group: Group::Quit, group: Group::Quit,
symbol: "Ctrl + c", symbol: "Ctrl + c",
description: "Quit", description: "Quit",
}, },
KeyBinding { KeyBinding {
action: Action::Up, action: Action::Up,
code: KeyCode::Up, code: KeyCode::Up,
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Movement, group: Group::Movement,
symbol: "↑", symbol: "↑",
description: "Up", description: "Up",
}, },
KeyBinding { KeyBinding {
action: Action::Down, action: Action::Down,
code: KeyCode::Down, code: KeyCode::Down,
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Movement, group: Group::Movement,
symbol: "↓", symbol: "↓",
description: "Down", description: "Down",
}, },
KeyBinding { KeyBinding {
action: Action::Left, action: Action::Left,
code: KeyCode::Left, code: KeyCode::Left,
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Movement, group: Group::Movement,
symbol: "←", symbol: "←",
description: "Left", description: "Left",
}, },
KeyBinding { KeyBinding {
action: Action::Right, action: Action::Right,
code: KeyCode::Right, code: KeyCode::Right,
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Movement, group: Group::Movement,
symbol: "→", symbol: "→",
description: "Right", description: "Right",
}, },
KeyBinding { KeyBinding {
action: Action::ScrollUp, action: Action::ScrollUp,
code: KeyCode::Up, code: KeyCode::Up,
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL, modifiers: KeyModifiers::CONTROL,
group: Group::CtrlMovement, group: Group::CtrlMovement,
symbol: "Ctrl + ↑", symbol: "Ctrl + ↑",
description: "Scroll Up ", description: "Scroll Up ",
}, },
KeyBinding { KeyBinding {
action: Action::ScrollDown, action: Action::ScrollDown,
code: KeyCode::Down, code: KeyCode::Down,
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL, modifiers: KeyModifiers::CONTROL,
group: Group::CtrlMovement, group: Group::CtrlMovement,
symbol: "Ctrl + ↓", symbol: "Ctrl + ↓",
description: "Scroll Down", description: "Scroll Down",
}, },
KeyBinding { KeyBinding {
action: Action::ScrollLeft, action: Action::ScrollLeft,
code: KeyCode::Left, code: KeyCode::Left,
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL, modifiers: KeyModifiers::CONTROL,
group: Group::CtrlMovement, group: Group::CtrlMovement,
symbol: "Ctrl + ←", symbol: "Ctrl + ←",
description: "Scroll Left", description: "Scroll Left",
}, },
KeyBinding { KeyBinding {
action: Action::ScrollRight, action: Action::ScrollRight,
code: KeyCode::Right, code: KeyCode::Right,
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL, modifiers: KeyModifiers::CONTROL,
group: Group::CtrlMovement, group: Group::CtrlMovement,
symbol: "Ctrl + →", symbol: "Ctrl + →",
description: "Scroll Right", description: "Scroll Right",
}, },
KeyBinding { KeyBinding {
action: Action::Space, action: Action::Space,
code: KeyCode::Char(' '), code: KeyCode::Char(' '),
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Select, group: Group::Select,
symbol: "Space", symbol: "Space",
description: "Marking cells", description: "Marking cells",
}, },
KeyBinding { KeyBinding {
action: Action::Backspace, action: Action::Backspace,
code: KeyCode::Backspace, code: KeyCode::Backspace,
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Select, group: Group::Select,
symbol: "Backspace", symbol: "Backspace",
description: "Unmark cell", description: "Unmark cell",
}, },
KeyBinding { KeyBinding {
action: Action::Delete, action: Action::Delete,
code: KeyCode::Delete, code: KeyCode::Delete,
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Select, group: Group::Select,
symbol: "Delete", symbol: "Delete",
description: "Cancel marking", description: "Cancel marking",
}, },
KeyBinding { KeyBinding {
action: Action::Enter, action: Action::Enter,
code: KeyCode::Enter, code: KeyCode::Enter,
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Select, group: Group::Select,
symbol: "Enter", symbol: "Enter",
description: "Submit", description: "Submit",
}, },
KeyBinding { KeyBinding {
action: Action::Esc, action: Action::Esc,
code: KeyCode::Esc, code: KeyCode::Esc,
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Quit, group: Group::Quit,
symbol: "Esc", symbol: "Esc",
description: "Main menu", description: "Main menu",
}, },
KeyBinding { KeyBinding {
action: Action::ZoomIn, action: Action::ZoomIn,
code: KeyCode::Char(','), code: KeyCode::Char(','),
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Zoom, group: Group::Zoom,
symbol: ",", symbol: ",",
description: "Zoom in", description: "Zoom in",
}, },
KeyBinding { KeyBinding {
action: Action::ZoomOut, action: Action::ZoomOut,
code: KeyCode::Char('.'), code: KeyCode::Char('.'),
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Zoom, group: Group::Zoom,
symbol: ".", symbol: ".",
description: "Zoom out", description: "Zoom out",
}, },
KeyBinding { KeyBinding {
action: Action::Mute, action: Action::Mute,
code: KeyCode::Char('m'), code: KeyCode::Char('m'),
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Music, group: Group::Music,
symbol: "m", symbol: "m",
description: "🔈", description: "🔈",
}, },
KeyBinding { KeyBinding {
action: Action::VolumeUp, action: Action::VolumeUp,
code: KeyCode::Char('b'), code: KeyCode::Char('b'),
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Music, group: Group::Music,
symbol: "b", symbol: "b",
description: "🔊", description: "🔊",
}, },
KeyBinding { KeyBinding {
action: Action::VolumeDown, action: Action::VolumeDown,
code: KeyCode::Char('n'), code: KeyCode::Char('n'),
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Music, group: Group::Music,
symbol: "n", symbol: "n",
description: "🔉", description: "🔉",
}, },
KeyBinding { KeyBinding {
action: Action::Tab, action: Action::Tab,
code: KeyCode::Tab, code: KeyCode::Tab,
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Opener, group: Group::Opener,
symbol: "Tab", symbol: "Tab",
description: "Toggle side panel", description: "Toggle side panel",
}, },
KeyBinding { KeyBinding {
action: Action::ShiftTab, action: Action::ShiftTab,
code: KeyCode::Tab, code: KeyCode::Tab,
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::SHIFT, modifiers: KeyModifiers::SHIFT,
group: Group::Opener, group: Group::Opener,
symbol: "Shift + Tab", symbol: "Shift + Tab",
description: "Toggle Map/Skills window", description: "Toggle Map/Skills window",
}, },
KeyBinding { KeyBinding {
action: Action::WildCard('_'), action: Action::WildCard('_'),
code: KeyCode::Char('_'), code: KeyCode::Char('_'),
kind: KeyEventKind::Press, kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
group: Group::Input, group: Group::Input,
symbol: "[All]", symbol: "[All]",
description: "All keyboard characters", description: "All keyboard characters",
}, },
]; ];
/// Returns the `KeyBinding` associated with the given `Action`, if one exists. /// Returns the `KeyBinding` associated with the given `Action`, if one exists.
pub fn binding_for(action: Action) -> Option<&'static KeyBinding> { pub fn binding_for(action: Action) -> Option<&'static KeyBinding> {
KEYBINDINGS.iter().find(|b| b.action == action) KEYBINDINGS.iter().find(|b| b.action == action)
} }
/// Converts a raw `KeyEvent` into an `Action` if a matching binding is found. /// Converts a raw `KeyEvent` into an `Action` if a matching binding is found.
@@ -361,30 +361,30 @@ pub fn binding_for(action: Action) -> Option<&'static KeyBinding> {
/// was pressed. Returns `None` when the event does not correspond to any known /// was pressed. Returns `None` when the event does not correspond to any known
/// action. /// action.
pub fn event_to_action(event: &KeyEvent) -> Option<Action> { pub fn event_to_action(event: &KeyEvent) -> Option<Action> {
// Legacy terminals encode Ctrl+Space as NUL instead of a modified space. // Legacy terminals encode Ctrl+Space as NUL instead of a modified space.
if event.code == KeyCode::Null if event.code == KeyCode::Null
&& event.kind == KeyEventKind::Press && event.kind == KeyEventKind::Press
&& (event.modifiers == KeyModifiers::NONE || event.modifiers == KeyModifiers::CONTROL) && (event.modifiers == KeyModifiers::NONE || event.modifiers == KeyModifiers::CONTROL)
{ {
return Some(Action::Building); return Some(Action::Building);
} }
if let Some(b) = KEYBINDINGS if let Some(b) = KEYBINDINGS
.iter() .iter()
.find(|b| b.code == event.code && b.kind == event.kind && b.modifiers == event.modifiers) .find(|b| b.code == event.code && b.kind == event.kind && b.modifiers == event.modifiers)
{ {
return Some(b.action); return Some(b.action);
}
if KEYBINDINGS
.iter()
.any(|b| matches!(b.action, Action::WildCard(_)) && b.kind == event.kind)
{
if let KeyCode::Char(c) = event.code {
return Some(Action::WildCard(c));
} }
}
None if KEYBINDINGS
.iter()
.any(|b| matches!(b.action, Action::WildCard(_)) && b.kind == event.kind)
{
if let KeyCode::Char(c) = event.code {
return Some(Action::WildCard(c));
}
}
None
} }
/// Determines the size of the largest group among a slice of actions. /// Determines the size of the largest group among a slice of actions.
@@ -393,13 +393,13 @@ pub fn event_to_action(event: &KeyEvent) -> Option<Action> {
/// returns the highest count. This can be used, for example, to size UI /// returns the highest count. This can be used, for example, to size UI
/// elements that display groups of bindings. /// elements that display groups of bindings.
pub fn count_largest_group(actions: &[Action]) -> u16 { pub fn count_largest_group(actions: &[Action]) -> u16 {
let mut group_counts: HashMap<Group, u16> = HashMap::new(); let mut group_counts: HashMap<Group, u16> = HashMap::new();
for action in actions { for action in actions {
if let Some(binding) = binding_for(*action) { if let Some(binding) = binding_for(*action) {
*group_counts.entry(binding.group).or_insert(0) += 1; *group_counts.entry(binding.group).or_insert(0) += 1;
}
} }
}
group_counts.values().copied().max().map_or(0, |v| v) group_counts.values().copied().max().map_or(0, |v| v)
} }
+27 -25
View File
@@ -1,36 +1,38 @@
use crate::app::{ use crate::app::{
App, View, App, View,
keybindings::{Action, common_keybindings, event_to_action}, keybindings::{Action, common_keybindings, event_to_action},
}; };
use ratatui::crossterm::event::KeyEvent; use ratatui::crossterm::event::KeyEvent;
pub fn main_menu_keybindings(app: &mut App, event: &KeyEvent) { pub fn main_menu_keybindings(app: &mut App, event: &KeyEvent) {
if let Some(action) = event_to_action(&event) { if let Some(action) = event_to_action(&event) {
common_keybindings(app, action); common_keybindings(app, action);
let Some(states) = app.states_mut() else { let Some(states) = app.states_mut() else {
return; return;
}; };
match action { match action {
Action::Up => { Action::Up => {
states.main_menu.selected_view = states.main_menu.selected_view.saturating_sub(1).max(1); states.main_menu.selected_view =
} states.main_menu.selected_view.saturating_sub(1).max(1);
Action::Down => { }
states.main_menu.selected_view = states.main_menu.selected_view.saturating_add(1).min(3); Action::Down => {
} states.main_menu.selected_view =
Action::Space => { states.main_menu.selected_view.saturating_add(1).min(3);
let selected_view: usize = states.main_menu.selected_view; }
Action::Space => {
let selected_view: usize = states.main_menu.selected_view;
if selected_view == 1 { if selected_view == 1 {
app.view = View::Skirmish; app.view = View::Skirmish;
} else if selected_view == 2 { } else if selected_view == 2 {
app.view = View::PerkDecks; app.view = View::PerkDecks;
} else if selected_view == 3 { } else if selected_view == 3 {
app.view = View::SkillsConfig; app.view = View::SkillsConfig;
}
}
_ => (),
} }
}
_ => (),
} }
}
} }
+1 -1
View File
@@ -5,7 +5,7 @@ mod skirmish;
pub use default::{common_keybindings, default_keybindings}; pub use default::{common_keybindings, default_keybindings};
pub use keybindings::{ pub use keybindings::{
Action, Group, KeyBinding, binding_for, count_largest_group, event_to_action, Action, Group, KeyBinding, binding_for, count_largest_group, event_to_action,
}; };
pub use main_menu::main_menu_keybindings; pub use main_menu::main_menu_keybindings;
pub use skirmish::{available_skirmish_actions, skirmish_keybindings}; pub use skirmish::{available_skirmish_actions, skirmish_keybindings};
+193 -190
View File
@@ -1,213 +1,216 @@
use crate::app::{ use crate::app::{
App, App,
keybindings::{Action, common_keybindings, event_to_action}, keybindings::{Action, common_keybindings, event_to_action},
states::{ states::{
SkirmishState, SkirmishState,
skirmish_states::{ skirmish_states::{
BoardState, FocusedCell, MoveFocusedCell, Players, ZoomLevel, BoardState, FocusedCell, MoveFocusedCell, Players, ZoomLevel,
structures::{Structure, Structures}, structures::{Structure, Structures},
units::{Unit, Units}, units::{Unit, Units},
},
}, },
},
}; };
use ratatui::{crossterm::event::KeyEvent, layout::Rect}; use ratatui::{crossterm::event::KeyEvent, layout::Rect};
pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> { pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
if state.recruitment_view { if state.recruitment_view {
let mut actions: Vec<Action> = vec![Action::Recruitment, Action::Esc, Action::Quit2]; let mut actions: Vec<Action> = vec![Action::Recruitment, Action::Esc, Action::Quit2];
if state.can_recruit_miner(state.board.get_focused_cell().get_coords(), Players::Player) { if state.can_recruit_miner(state.board.get_focused_cell().get_coords(), Players::Player) {
actions.push(Action::Enter); actions.push(Action::Enter);
}
return actions;
} }
return actions; if state.building_view {
} let mut actions: Vec<Action> = vec![Action::Building, Action::Esc, Action::Quit2];
if state.building_view { let coords: (usize, usize) = state.board.get_focused_cell().get_coords();
let mut actions: Vec<Action> = vec![Action::Building, Action::Esc, Action::Quit2]; if let Structures::MineConstruction(site) =
let coords: (usize, usize) = state.board.get_focused_cell().get_coords(); state.board.get_ref_cell(coords.0, coords.1).get_structure()
if let Structures::MineConstruction(site) = && site.get_owner() == Players::Player
state.board.get_ref_cell(coords.0, coords.1).get_structure() {
&& site.get_owner() == Players::Player actions.push(Action::Delete);
{ } else if state.can_start_mine_construction(coords, Players::Player) {
actions.push(Action::Delete); actions.push(Action::Enter);
} else if state.can_start_mine_construction(coords, Players::Player) { }
actions.push(Action::Enter); return actions;
}
let mut actions: Vec<Action> = vec![Action::Up, Action::Down, Action::Left, Action::Right];
if state.board.marked_cells.marking_cells {
actions.extend([
Action::Space,
Action::Backspace,
Action::Delete,
Action::Esc,
Action::Tab,
Action::Quit2,
]);
return actions;
} }
return actions;
}
let mut actions: Vec<Action> = vec![Action::Up, Action::Down, Action::Left, Action::Right];
if state.board.marked_cells.marking_cells {
actions.extend([ actions.extend([
Action::Space, Action::ScrollUp,
Action::Backspace, Action::ScrollDown,
Action::Delete, Action::ScrollLeft,
Action::Esc, Action::ScrollRight,
Action::Tab, Action::Tab,
Action::Quit2, Action::Building,
Action::Mute,
Action::Quit,
Action::Esc,
]); ]);
return actions; if state.board.zoom_level != ZoomLevel::ZoomedIn {
} actions.push(Action::ZoomIn);
actions.extend([ }
Action::ScrollUp, if state.board.zoom_level != ZoomLevel::ZoomedOut {
Action::ScrollDown, actions.push(Action::ZoomOut);
Action::ScrollLeft, }
Action::ScrollRight, let cell: &FocusedCell = state.board.get_focused_cell();
Action::Tab, let (row, col): (usize, usize) = cell.get_coords();
Action::Building, if matches!(state.board.get_ref_cell(row, col).get_structure(), Structures::Base(base) if base.get_owner() == Players::Player)
Action::Mute, {
Action::Quit, actions.push(Action::Recruitment);
Action::Esc, }
]); let unit: &Option<Units> = state.board.get_ref_cell(row, col).get_ref_option_unit();
if state.board.zoom_level != ZoomLevel::ZoomedIn { if unit.get_owner() == Players::Player && unit.get_can_dig() {
actions.push(Action::ZoomIn); actions.push(Action::Space);
} }
if state.board.zoom_level != ZoomLevel::ZoomedOut { actions
actions.push(Action::ZoomOut);
}
let cell: &FocusedCell = state.board.get_focused_cell();
let (row, col): (usize, usize) = cell.get_coords();
if matches!(state.board.get_ref_cell(row, col).get_structure(), Structures::Base(base) if base.get_owner() == Players::Player)
{
actions.push(Action::Recruitment);
}
let unit: &Option<Units> = state.board.get_ref_cell(row, col).get_ref_option_unit();
if unit.get_owner() == Players::Player && unit.get_can_dig() {
actions.push(Action::Space);
}
actions
} }
pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () { pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () {
if let Some(action) = event_to_action(key_event) { if let Some(action) = event_to_action(key_event) {
let Some(states) = app.states_mut() else { let Some(states) = app.states_mut() else {
common_keybindings(app, action); common_keybindings(app, action);
return; return;
}; };
if states.skirmish.recruitment_view { if states.skirmish.recruitment_view {
match action { match action {
Action::Recruitment | Action::Esc => states.skirmish.recruitment_view = false, Action::Recruitment | Action::Esc => states.skirmish.recruitment_view = false,
Action::Enter => { Action::Enter => {
let coords: (usize, usize) = states.skirmish.board.get_focused_cell().get_coords(); let coords: (usize, usize) =
states.skirmish.recruit_miner(coords, Players::Player); states.skirmish.board.get_focused_cell().get_coords();
states.skirmish.recruit_miner(coords, Players::Player);
}
_ => common_keybindings(app, action),
}
return;
} }
_ => common_keybindings(app, action), if states.skirmish.building_view {
} match action {
return; Action::Building | Action::Esc => states.skirmish.building_view = false,
} Action::Enter => {
if states.skirmish.building_view { let coords: (usize, usize) =
match action { states.skirmish.board.get_focused_cell().get_coords();
Action::Building | Action::Esc => states.skirmish.building_view = false, states
Action::Enter => { .skirmish
let coords: (usize, usize) = states.skirmish.board.get_focused_cell().get_coords(); .start_mine_construction(coords, Players::Player);
states }
.skirmish Action::Delete => {
.start_mine_construction(coords, Players::Player); let coords: (usize, usize) =
states.skirmish.board.get_focused_cell().get_coords();
states
.skirmish
.cancel_mine_construction(coords, Players::Player);
}
_ => common_keybindings(app, action),
}
return;
} }
Action::Delete => { if action == Action::Esc && states.skirmish.board.marked_cells.marking_cells {
let coords: (usize, usize) = states.skirmish.board.get_focused_cell().get_coords(); states.skirmish.board.marked_cells.marking_cells = false;
states states.skirmish.board.end_marking_cells(true);
.skirmish return;
.cancel_mine_construction(coords, Players::Player); }
let available: bool = available_skirmish_actions(&states.skirmish).contains(&action);
common_keybindings(app, action);
if !available {
return;
} }
_ => common_keybindings(app, action),
}
return;
}
if action == Action::Esc && states.skirmish.board.marked_cells.marking_cells {
states.skirmish.board.marked_cells.marking_cells = false;
states.skirmish.board.end_marking_cells(true);
return;
}
let available: bool = available_skirmish_actions(&states.skirmish).contains(&action);
common_keybindings(app, action);
if !available {
return;
}
let window_area: Rect = app.window_area; let window_area: Rect = app.window_area;
let Some(states) = app.states_mut() else { let Some(states) = app.states_mut() else {
return; return;
}; };
let board: &mut BoardState = &mut states.skirmish.board; let board: &mut BoardState = &mut states.skirmish.board;
match action { match action {
Action::Up => { Action::Up => {
board.change_focused_cell(MoveFocusedCell::Up); board.change_focused_cell(MoveFocusedCell::Up);
if !board.is_focused_cell_visible() { if !board.is_focused_cell_visible() {
board.vertical_offset.prev(); board.vertical_offset.prev();
}
}
Action::Down => {
board.change_focused_cell(MoveFocusedCell::Down);
if !board.is_focused_cell_visible() {
board.vertical_offset.next();
}
}
Action::Left => {
board.change_focused_cell(MoveFocusedCell::Left);
if !board.is_focused_cell_visible() {
board.horizontal_offset.prev();
}
}
Action::Right => {
board.change_focused_cell(MoveFocusedCell::Right);
if !board.is_focused_cell_visible() {
board.horizontal_offset.next();
}
}
Action::ScrollUp => {
board.vertical_offset.prev();
board.change_focused_cell(MoveFocusedCell::Up);
}
Action::ScrollDown => {
board.vertical_offset.next();
board.change_focused_cell(MoveFocusedCell::Down);
}
Action::ScrollLeft => {
board.horizontal_offset.prev();
board.change_focused_cell(MoveFocusedCell::Left);
}
Action::ScrollRight => {
board.horizontal_offset.next();
board.change_focused_cell(MoveFocusedCell::Right);
}
Action::ZoomIn => match board.zoom_level {
ZoomLevel::ZoomedIn => {}
ZoomLevel::Default => {
board.change_zoom(ZoomLevel::ZoomedIn);
board.vertical_offset.next();
}
ZoomLevel::ZoomedOut => {
board.change_zoom(ZoomLevel::Default);
board.vertical_offset.next();
board.vertical_offset.next();
}
},
Action::ZoomOut => match board.zoom_level {
ZoomLevel::ZoomedIn => {
board.change_zoom(ZoomLevel::Default);
board.vertical_offset.prev();
}
ZoomLevel::Default => {
board.change_zoom(ZoomLevel::ZoomedOut);
board.vertical_offset.prev();
board.vertical_offset.prev();
}
ZoomLevel::ZoomedOut => {}
},
Action::Space => board.toggle_marking(),
Action::Building => states.skirmish.building_view = true,
Action::Recruitment => states.skirmish.recruitment_view = true,
Action::Backspace => board.pop_marked_cell(),
Action::Delete => {
board.marked_cells.marking_cells = false;
board.end_marking_cells(true)
}
Action::Tab => {
states.skirmish.side_panel = !states.skirmish.side_panel;
board.change_resize(&window_area, states.skirmish.side_panel);
}
_ => (),
} }
}
Action::Down => {
board.change_focused_cell(MoveFocusedCell::Down);
if !board.is_focused_cell_visible() {
board.vertical_offset.next();
}
}
Action::Left => {
board.change_focused_cell(MoveFocusedCell::Left);
if !board.is_focused_cell_visible() {
board.horizontal_offset.prev();
}
}
Action::Right => {
board.change_focused_cell(MoveFocusedCell::Right);
if !board.is_focused_cell_visible() {
board.horizontal_offset.next();
}
}
Action::ScrollUp => {
board.vertical_offset.prev();
board.change_focused_cell(MoveFocusedCell::Up);
}
Action::ScrollDown => {
board.vertical_offset.next();
board.change_focused_cell(MoveFocusedCell::Down);
}
Action::ScrollLeft => {
board.horizontal_offset.prev();
board.change_focused_cell(MoveFocusedCell::Left);
}
Action::ScrollRight => {
board.horizontal_offset.next();
board.change_focused_cell(MoveFocusedCell::Right);
}
Action::ZoomIn => match board.zoom_level {
ZoomLevel::ZoomedIn => {}
ZoomLevel::Default => {
board.change_zoom(ZoomLevel::ZoomedIn);
board.vertical_offset.next();
}
ZoomLevel::ZoomedOut => {
board.change_zoom(ZoomLevel::Default);
board.vertical_offset.next();
board.vertical_offset.next();
}
},
Action::ZoomOut => match board.zoom_level {
ZoomLevel::ZoomedIn => {
board.change_zoom(ZoomLevel::Default);
board.vertical_offset.prev();
}
ZoomLevel::Default => {
board.change_zoom(ZoomLevel::ZoomedOut);
board.vertical_offset.prev();
board.vertical_offset.prev();
}
ZoomLevel::ZoomedOut => {}
},
Action::Space => board.toggle_marking(),
Action::Building => states.skirmish.building_view = true,
Action::Recruitment => states.skirmish.recruitment_view = true,
Action::Backspace => board.pop_marked_cell(),
Action::Delete => {
board.marked_cells.marking_cells = false;
board.end_marking_cells(true)
}
Action::Tab => {
states.skirmish.side_panel = !states.skirmish.side_panel;
board.change_resize(&window_area, states.skirmish.side_panel);
}
_ => (),
} }
}
} }
+55 -55
View File
@@ -1,66 +1,66 @@
use crate::{ use crate::{
app::states::{ app::states::{
MainMenuState, PerkDecksState, SettingsState, SkillsConfigState, SkirmishState, MainMenuState, PerkDecksState, SettingsState, SkillsConfigState, SkirmishState,
skirmish_states::{BoardState, Resources}, skirmish_states::{BoardState, Resources},
}, },
cli::Cli, cli::Cli,
}; };
use ratatui::layout::Rect; use ratatui::layout::Rect;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct GameStates { pub struct GameStates {
pub main_menu: MainMenuState, pub main_menu: MainMenuState,
pub skirmish: SkirmishState, pub skirmish: SkirmishState,
pub perk_decks: PerkDecksState, pub perk_decks: PerkDecksState,
pub skills_config: SkillsConfigState, pub skills_config: SkillsConfigState,
pub settings: SettingsState, pub settings: SettingsState,
} }
impl GameStates { impl GameStates {
pub fn new(args: &Cli, area: &Rect) -> Self { pub fn new(args: &Cli, area: &Rect) -> Self {
Self { Self {
main_menu: MainMenuState { main_menu: MainMenuState {
id: 0, id: 0,
name: "Main Menu", name: "Main Menu",
selected_view: 1, selected_view: 1,
}, },
skirmish: SkirmishState { skirmish: SkirmishState {
id: 1, id: 1,
name: "Skirmish", name: "Skirmish",
board: BoardState::new( board: BoardState::new(
area, area,
args.map_width as usize, args.map_width as usize,
args.map_height as usize, args.map_height as usize,
args.zoom_level, args.zoom_level,
false, false,
), ),
side_panel: false, side_panel: false,
turn_counter: 0, turn_counter: 0,
player_resources: Resources::new(args.starting_wood, args.starting_metal), player_resources: Resources::new(args.starting_wood, args.starting_metal),
enemy_resources: Resources::new(args.starting_wood, args.starting_metal), enemy_resources: Resources::new(args.starting_wood, args.starting_metal),
building_view: false, building_view: false,
recruitment_view: false, recruitment_view: false,
}, },
perk_decks: PerkDecksState { perk_decks: PerkDecksState {
id: 2, id: 2,
name: "Perk Decks", name: "Perk Decks",
selected_perk_deck: 0, selected_perk_deck: 0,
}, },
skills_config: SkillsConfigState { skills_config: SkillsConfigState {
id: 3, id: 3,
name: "Skills Config", name: "Skills Config",
selected_skill: 0, selected_skill: 0,
}, },
settings: SettingsState { settings: SettingsState {
username: args.username.clone(), username: args.username.clone(),
game_mode: args.game_mode.clone(), game_mode: args.game_mode.clone(),
perk_deck: args.perk_deck.clone(), perk_deck: args.perk_deck.clone(),
starting_wood: args.starting_wood.clone(), starting_wood: args.starting_wood.clone(),
starting_metal: args.starting_metal, starting_metal: args.starting_metal,
supply_limit: args.supply_limit.clone(), supply_limit: args.supply_limit.clone(),
xp_modifier: args.xp_modifier.clone(), xp_modifier: args.xp_modifier.clone(),
skill_points_limit: args.skill_points_limit.clone(), skill_points_limit: args.skill_points_limit.clone(),
}, },
}
} }
}
} }
+3 -3
View File
@@ -1,6 +1,6 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MainMenuState { pub struct MainMenuState {
pub id: usize, pub id: usize,
pub name: &'static str, pub name: &'static str,
pub selected_view: usize, pub selected_view: usize,
} }
+12 -12
View File
@@ -3,24 +3,24 @@ use std::fmt::{Display, Formatter, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PerkDecksState { pub struct PerkDecksState {
pub id: usize, pub id: usize,
pub name: &'static str, pub name: &'static str,
pub selected_perk_deck: usize, pub selected_perk_deck: usize,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
pub enum PerkDecks { pub enum PerkDecks {
Silesian, Silesian,
BogeyMan, BogeyMan,
Anteater, Anteater,
} }
impl Display for PerkDecks { impl Display for PerkDecks {
fn fmt(&self, f: &mut Formatter<'_>) -> Result { fn fmt(&self, f: &mut Formatter<'_>) -> Result {
match self { match self {
PerkDecks::Silesian => write!(f, "Silesian"), PerkDecks::Silesian => write!(f, "Silesian"),
PerkDecks::BogeyMan => write!(f, "Bogey Man"), PerkDecks::BogeyMan => write!(f, "Bogey Man"),
PerkDecks::Anteater => write!(f, "Anteater"), PerkDecks::Anteater => write!(f, "Anteater"),
}
} }
}
} }
+8 -8
View File
@@ -2,12 +2,12 @@ use crate::app::states::{PerkDecks, skirmish_states::GameMode};
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct SettingsState { pub struct SettingsState {
pub username: String, pub username: String,
pub game_mode: GameMode, pub game_mode: GameMode,
pub perk_deck: PerkDecks, pub perk_deck: PerkDecks,
pub starting_wood: u16, pub starting_wood: u16,
pub starting_metal: u16, pub starting_metal: u16,
pub supply_limit: u8, pub supply_limit: u8,
pub xp_modifier: f32, pub xp_modifier: f32,
pub skill_points_limit: u16, pub skill_points_limit: u16,
} }
+3 -3
View File
@@ -1,6 +1,6 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SkillsConfigState { pub struct SkillsConfigState {
pub id: usize, pub id: usize,
pub name: &'static str, pub name: &'static str,
pub selected_skill: usize, pub selected_skill: usize,
} }
+103 -102
View File
@@ -1,127 +1,128 @@
use crate::app::states::skirmish_states::{ use crate::app::states::skirmish_states::{
BoardState, Players, Resources, BoardState, Players, Resources,
resources::{MINE_PRODUCTION_TICKS, WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_TICKS}, resources::{MINE_PRODUCTION_TICKS, WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_TICKS},
structures::{BuildingTerrain, Mine, Structure, Structures}, structures::{BuildingTerrain, Mine, Structure, Structures},
units::MinerUnit, units::MinerUnit,
}; };
use crate::app::widgets::CellWidget; use crate::app::widgets::CellWidget;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct SkirmishState { pub struct SkirmishState {
pub id: usize, pub id: usize,
pub name: &'static str, pub name: &'static str,
pub board: BoardState, pub board: BoardState,
pub side_panel: bool, pub side_panel: bool,
pub turn_counter: u64, pub turn_counter: u64,
pub player_resources: Resources, pub player_resources: Resources,
pub enemy_resources: Resources, pub enemy_resources: Resources,
pub building_view: bool, pub building_view: bool,
pub recruitment_view: bool, pub recruitment_view: bool,
} }
impl SkirmishState { impl SkirmishState {
pub fn can_recruit_miner(&self, (row, col): (usize, usize), owner: Players) -> bool { pub fn can_recruit_miner(&self, (row, col): (usize, usize), owner: Players) -> bool {
let resources: &Resources = match owner { let resources: &Resources = match owner {
Players::Player => &self.player_resources, Players::Player => &self.player_resources,
Players::Enemy => &self.enemy_resources, Players::Enemy => &self.enemy_resources,
Players::Unclaimed => return false, Players::Unclaimed => return false,
}; };
if !resources.can_afford(MinerUnit::RECRUIT_COST) { if !resources.can_afford(MinerUnit::RECRUIT_COST) {
return false; return false;
} }
self.board.cells.get(row).and_then(|cells: &Vec<CellWidget>| -> Option<&CellWidget> { cells.get(col) }) self.board.cells.get(row).and_then(|cells: &Vec<CellWidget>| -> Option<&CellWidget> { cells.get(col) })
.is_some_and(|cell: &CellWidget| -> bool { .is_some_and(|cell: &CellWidget| -> bool {
matches!(cell.get_structure(), Structures::Base(base) if base.get_owner() == owner && base.can_queue_miner()) matches!(cell.get_structure(), Structures::Base(base) if base.get_owner() == owner && base.can_queue_miner())
}) })
}
pub fn recruit_miner(&mut self, coords: (usize, usize), owner: Players) -> bool {
if !self.can_recruit_miner(coords, owner) {
return false;
} }
let cell: &mut CellWidget = &mut self.board.cells[coords.0][coords.1];
let Structures::Base(mut base) = cell.get_structure() else {
return false;
};
if !base.queue_miner() {
return false;
}
cell.set_structure(Structures::Base(base));
let resources: &mut Resources = if owner == Players::Player {
&mut self.player_resources
} else {
&mut self.enemy_resources
};
resources.spend(MinerUnit::RECRUIT_COST)
}
pub fn can_start_mine_construction(&self, coords: (usize, usize), owner: Players) -> bool { pub fn recruit_miner(&mut self, coords: (usize, usize), owner: Players) -> bool {
let resources: &Resources = match owner { if !self.can_recruit_miner(coords, owner) {
Players::Player => &self.player_resources, return false;
Players::Enemy => &self.enemy_resources, }
Players::Unclaimed => return false, let cell: &mut CellWidget = &mut self.board.cells[coords.0][coords.1];
}; let Structures::Base(mut base) = cell.get_structure() else {
resources.can_afford(Mine::COST) && self.board.can_build_at(coords, owner, BuildingTerrain::Ore) return false;
} };
if !base.queue_miner() {
return false;
}
cell.set_structure(Structures::Base(base));
let resources: &mut Resources = if owner == Players::Player {
&mut self.player_resources
} else {
&mut self.enemy_resources
};
resources.spend(MinerUnit::RECRUIT_COST)
}
pub fn can_start_mine_construction(&self, coords: (usize, usize), owner: Players) -> bool {
let resources: &Resources = match owner {
Players::Player => &self.player_resources,
Players::Enemy => &self.enemy_resources,
Players::Unclaimed => return false,
};
resources.can_afford(Mine::COST)
&& self.board.can_build_at(coords, owner, BuildingTerrain::Ore)
}
pub fn start_mine_construction(&mut self, coords: (usize, usize), owner: Players) -> bool { pub fn start_mine_construction(&mut self, coords: (usize, usize), owner: Players) -> bool {
if !self.can_start_mine_construction(coords, owner) if !self.can_start_mine_construction(coords, owner)
|| !self.board.start_mine_construction(coords, owner) || !self.board.start_mine_construction(coords, owner)
{ {
return false; return false;
}
let resources: &mut Resources = match owner {
Players::Player => &mut self.player_resources,
Players::Enemy => &mut self.enemy_resources,
Players::Unclaimed => return false,
};
resources.spend(Mine::COST)
} }
let resources: &mut Resources = match owner {
Players::Player => &mut self.player_resources,
Players::Enemy => &mut self.enemy_resources,
Players::Unclaimed => return false,
};
resources.spend(Mine::COST)
}
pub fn cancel_mine_construction(&mut self, coords: (usize, usize), owner: Players) -> bool { pub fn cancel_mine_construction(&mut self, coords: (usize, usize), owner: Players) -> bool {
if owner == Players::Unclaimed || !self.board.cancel_mine_construction(coords, owner) { if owner == Players::Unclaimed || !self.board.cancel_mine_construction(coords, owner) {
return false; return false;
}
let resources: &mut Resources = if owner == Players::Player {
&mut self.player_resources
} else {
&mut self.enemy_resources
};
resources.add_wood(Mine::COST.wood);
resources.add_metal(Mine::COST.metal);
true
} }
let resources: &mut Resources = if owner == Players::Player {
&mut self.player_resources
} else {
&mut self.enemy_resources
};
resources.add_wood(Mine::COST.wood);
resources.add_metal(Mine::COST.metal);
true
}
pub fn tick_update(&mut self) -> () { pub fn tick_update(&mut self) -> () {
self.board.advance_turn(); self.board.advance_turn();
self.board.advance_recruitment(); self.board.advance_recruitment();
self.board.advance_construction(); self.board.advance_construction();
// if self.board.is_victory() {} // if self.board.is_victory() {}
self.turn_counter += 1; self.turn_counter += 1;
if self.turn_counter.is_multiple_of(WOOD_DELIVERY_TICKS) { if self.turn_counter.is_multiple_of(WOOD_DELIVERY_TICKS) {
self.player_resources.add_wood(WOOD_DELIVERY_AMOUNT); self.player_resources.add_wood(WOOD_DELIVERY_AMOUNT);
} }
if self.turn_counter.is_multiple_of(MINE_PRODUCTION_TICKS) { if self.turn_counter.is_multiple_of(MINE_PRODUCTION_TICKS) {
for row in &mut self.board.cells { for row in &mut self.board.cells {
for cell in row { for cell in row {
if let Structures::Mine(mut mine) = cell.get_structure() { if let Structures::Mine(mut mine) = cell.get_structure() {
let staffed: bool = cell.get_ref_option_unit().as_ref().is_some_and( let staffed: bool = cell.get_ref_option_unit().as_ref().is_some_and(
|unit: &crate::app::states::skirmish_states::units::Units| -> bool { |unit: &crate::app::states::skirmish_states::units::Units| -> bool {
mine.accepts_unit(unit) mine.accepts_unit(unit)
}, },
); );
let amount: u32 = u32::from(mine.produce_with_miner(staffed)); let amount: u32 = u32::from(mine.produce_with_miner(staffed));
match mine.get_owner() { match mine.get_owner() {
Players::Player => self.player_resources.add_metal(amount), Players::Player => self.player_resources.add_metal(amount),
Players::Enemy => self.enemy_resources.add_metal(amount), Players::Enemy => self.enemy_resources.add_metal(amount),
Players::Unclaimed => (), Players::Unclaimed => (),
}
cell.set_structure(Structures::Mine(mine));
}
}
} }
cell.set_structure(Structures::Mine(mine));
}
} }
}
} }
}
} }
File diff suppressed because it is too large. Load diff
+41 -41
View File
@@ -1,53 +1,53 @@
pub enum MoveFocusedCell { pub enum MoveFocusedCell {
Up, Up,
Down, Down,
Left, Left,
Right, Right,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FocusedCell { pub struct FocusedCell {
row: usize, row: usize,
col: usize, col: usize,
max_row: usize, max_row: usize,
max_col: usize, max_col: usize,
} }
impl FocusedCell { impl FocusedCell {
pub fn new(row: usize, col: usize, max_row: usize, max_col: usize) -> Self { pub fn new(row: usize, col: usize, max_row: usize, max_col: usize) -> Self {
Self { Self {
row, row,
col, col,
max_row, max_row,
max_col, max_col,
} }
}
pub fn get_coords(&self) -> (usize, usize) {
(self.row, self.col)
}
pub fn move_focused_cell(&mut self, direction: MoveFocusedCell) -> (usize, usize) {
match direction {
MoveFocusedCell::Up => {
self.row = self.row.saturating_sub(1).max(0);
}
MoveFocusedCell::Down => {
self.row = self.row.saturating_add(1).min(self.max_row - 1);
}
MoveFocusedCell::Left => {
self.col = self.col.saturating_sub(1).max(0);
}
MoveFocusedCell::Right => {
self.col = self.col.saturating_add(1).min(self.max_col - 1);
}
} }
(self.row, self.col) pub fn get_coords(&self) -> (usize, usize) {
} (self.row, self.col)
}
pub fn set_focused_cell(&mut self, cell: (usize, usize)) { pub fn move_focused_cell(&mut self, direction: MoveFocusedCell) -> (usize, usize) {
self.row = cell.0.max(0).min(self.max_row - 1); match direction {
self.col = cell.1.max(0).min(self.max_col - 1); MoveFocusedCell::Up => {
} self.row = self.row.saturating_sub(1).max(0);
}
MoveFocusedCell::Down => {
self.row = self.row.saturating_add(1).min(self.max_row - 1);
}
MoveFocusedCell::Left => {
self.col = self.col.saturating_sub(1).max(0);
}
MoveFocusedCell::Right => {
self.col = self.col.saturating_add(1).min(self.max_col - 1);
}
}
(self.row, self.col)
}
pub fn set_focused_cell(&mut self, cell: (usize, usize)) {
self.row = cell.0.max(0).min(self.max_row - 1);
self.col = cell.1.max(0).min(self.max_col - 1);
}
} }
+2 -2
View File
@@ -2,6 +2,6 @@ use clap::ValueEnum;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
pub enum GameMode { pub enum GameMode {
LastManStanding, LastManStanding,
FrontLines, FrontLines,
} }
@@ -3,17 +3,17 @@ use std::collections::VecDeque;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct MarkedCells { pub struct MarkedCells {
pub marking_cells: bool, pub marking_cells: bool,
pub marked_cells: VecDeque<(usize, usize)>, pub marked_cells: VecDeque<(usize, usize)>,
pub selected_unit: Option<Units>, pub selected_unit: Option<Units>,
} }
impl MarkedCells { impl MarkedCells {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
marking_cells: false, marking_cells: false,
marked_cells: VecDeque::new(), marked_cells: VecDeque::new(),
selected_unit: None, selected_unit: None,
}
} }
}
} }
+23 -23
View File
@@ -1,35 +1,35 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Offset { pub struct Offset {
value: usize, value: usize,
max: usize, max: usize,
} }
impl Offset { impl Offset {
pub fn new(initial_value: Option<usize>, initial_max: Option<usize>) -> Self { pub fn new(initial_value: Option<usize>, initial_max: Option<usize>) -> Self {
Self { Self {
value: initial_value.unwrap_or(0).min(initial_max.unwrap_or(0)), value: initial_value.unwrap_or(0).min(initial_max.unwrap_or(0)),
max: initial_max.unwrap_or(0), max: initial_max.unwrap_or(0),
}
} }
}
pub fn get_value(&self) -> usize { pub fn get_value(&self) -> usize {
self.value self.value
} }
pub fn set_value(&mut self, value: usize) { pub fn set_value(&mut self, value: usize) {
self.value = value.min(self.max); self.value = value.min(self.max);
} }
pub fn set_max(&mut self, max: usize) { pub fn set_max(&mut self, max: usize) {
self.max = max; self.max = max;
self.value = self.value.min(max); self.value = self.value.min(max);
} }
pub fn next(&mut self) { pub fn next(&mut self) {
self.value = self.value.saturating_add(1).min(self.max); self.value = self.value.saturating_add(1).min(self.max);
} }
pub fn prev(&mut self) { pub fn prev(&mut self) {
self.value = self.value.saturating_sub(1).max(0) self.value = self.value.saturating_sub(1).max(0)
} }
} }
+23 -23
View File
@@ -1,37 +1,37 @@
use ratatui::{ use ratatui::{
style::{Color, Stylize}, style::{Color, Stylize},
text::Span, text::Span,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Players { pub enum Players {
Player, Player,
Enemy, Enemy,
Unclaimed, Unclaimed,
} }
impl Players { impl Players {
pub fn get_text(&self) -> &'static str { pub fn get_text(&self) -> &'static str {
match self { match self {
Self::Player => "Player", Self::Player => "Player",
Self::Enemy => "Enemy", Self::Enemy => "Enemy",
Self::Unclaimed => "Unclaimed", Self::Unclaimed => "Unclaimed",
}
} }
}
pub fn get_color(&self) -> Color { pub fn get_color(&self) -> Color {
match self { match self {
Self::Player => Color::LightBlue, Self::Player => Color::LightBlue,
Self::Enemy => Color::LightRed, Self::Enemy => Color::LightRed,
Self::Unclaimed => Color::White, Self::Unclaimed => Color::White,
}
} }
}
pub fn get_span(&self) -> Span<'static> { pub fn get_span(&self) -> Span<'static> {
match self { match self {
Self::Player => self.get_text().fg(self.get_color()), Self::Player => self.get_text().fg(self.get_color()),
Self::Enemy => self.get_text().fg(self.get_color()), Self::Enemy => self.get_text().fg(self.get_color()),
Self::Unclaimed => self.get_text().fg(self.get_color()), Self::Unclaimed => self.get_text().fg(self.get_color()),
}
} }
}
} }
+24 -24
View File
@@ -7,35 +7,35 @@ pub const MINE_PRODUCTION_TICKS: u64 = 1000_u64.div_ceil(TICK_MS as u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Resources { pub struct Resources {
pub wood: u32, pub wood: u32,
pub metal: u32, pub metal: u32,
} }
impl Resources { impl Resources {
pub fn can_afford(&self, cost: Self) -> bool { pub fn can_afford(&self, cost: Self) -> bool {
self.wood >= cost.wood && self.metal >= cost.metal self.wood >= cost.wood && self.metal >= cost.metal
}
pub fn spend(&mut self, cost: Self) -> bool {
if !self.can_afford(cost) {
return false;
} }
self.wood -= cost.wood;
self.metal -= cost.metal; pub fn spend(&mut self, cost: Self) -> bool {
true if !self.can_afford(cost) {
} return false;
pub fn new(wood: u16, metal: u16) -> Self { }
Self { self.wood -= cost.wood;
wood: u32::from(wood), self.metal -= cost.metal;
metal: u32::from(metal), true
}
pub fn new(wood: u16, metal: u16) -> Self {
Self {
wood: u32::from(wood),
metal: u32::from(metal),
}
} }
}
pub fn add_wood(&mut self, amount: u32) -> () { pub fn add_wood(&mut self, amount: u32) -> () {
self.wood = self.wood.saturating_add(amount); self.wood = self.wood.saturating_add(amount);
} }
pub fn add_metal(&mut self, amount: u32) -> () { pub fn add_metal(&mut self, amount: u32) -> () {
self.metal = self.metal.saturating_add(amount); self.metal = self.metal.saturating_add(amount);
} }
} }
+108 -108
View File
@@ -4,135 +4,135 @@ use ratatui::{style::Color, text::Line};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BaseBuilding { pub struct BaseBuilding {
durability: u16, durability: u16,
stress: u8, stress: u8,
owner: Players, owner: Players,
level: u8, level: u8,
queued_miners: u32, queued_miners: u32,
recruitment_ticks: u16, recruitment_ticks: u16,
waiting_for_departure: bool, waiting_for_departure: bool,
} }
impl BaseBuilding { impl BaseBuilding {
pub const RECRUITMENT_TICKS: u16 = 10000_u16.div_ceil(TICK_MS as u16); pub const RECRUITMENT_TICKS: u16 = 10000_u16.div_ceil(TICK_MS as u16);
pub fn queued_miners(&self) -> u32 { pub fn queued_miners(&self) -> u32 {
self.queued_miners self.queued_miners
}
pub fn recruitment_progress(&self) -> u16 {
(u32::from(self.recruitment_ticks) * 100 / u32::from(Self::RECRUITMENT_TICKS)) as u16
}
pub fn can_queue_miner(&self) -> bool {
self.owner != Players::Unclaimed && self.queued_miners < u32::MAX
}
pub fn queue_miner(&mut self) -> bool {
if !self.can_queue_miner() {
return false;
} }
self.queued_miners += 1;
true
}
pub fn advance_recruitment(&mut self, occupied: bool) -> bool { pub fn recruitment_progress(&self) -> u16 {
if self.waiting_for_departure { (u32::from(self.recruitment_ticks) * 100 / u32::from(Self::RECRUITMENT_TICKS)) as u16
if occupied {
return false;
}
self.waiting_for_departure = false;
} }
if self.queued_miners == 0 {
return false;
}
self.recruitment_ticks = self
.recruitment_ticks
.saturating_add(1)
.min(Self::RECRUITMENT_TICKS);
if self.recruitment_ticks < Self::RECRUITMENT_TICKS || occupied {
return false;
}
self.queued_miners -= 1;
self.recruitment_ticks = 0;
self.waiting_for_departure = true;
true
}
pub fn recruitment_status(&self, occupied: bool) -> &'static str { pub fn can_queue_miner(&self) -> bool {
if self.queued_miners == 0 { self.owner != Players::Unclaimed && self.queued_miners < u32::MAX
"Queue empty"
} else if self.waiting_for_departure && occupied {
"Paused: waiting for Miner to leave base"
} else if self.recruitment_ticks == Self::RECRUITMENT_TICKS && occupied {
"Ready: waiting for free base cell"
} else {
"Recruiting"
} }
}
pub fn new(owner: Players) -> Self { pub fn queue_miner(&mut self) -> bool {
Self { if !self.can_queue_miner() {
durability: 1500, return false;
stress: 0, }
owner, self.queued_miners += 1;
level: 1, true
queued_miners: 0, }
recruitment_ticks: 0,
waiting_for_departure: false, pub fn advance_recruitment(&mut self, occupied: bool) -> bool {
if self.waiting_for_departure {
if occupied {
return false;
}
self.waiting_for_departure = false;
}
if self.queued_miners == 0 {
return false;
}
self.recruitment_ticks = self
.recruitment_ticks
.saturating_add(1)
.min(Self::RECRUITMENT_TICKS);
if self.recruitment_ticks < Self::RECRUITMENT_TICKS || occupied {
return false;
}
self.queued_miners -= 1;
self.recruitment_ticks = 0;
self.waiting_for_departure = true;
true
}
pub fn recruitment_status(&self, occupied: bool) -> &'static str {
if self.queued_miners == 0 {
"Queue empty"
} else if self.waiting_for_departure && occupied {
"Paused: waiting for Miner to leave base"
} else if self.recruitment_ticks == Self::RECRUITMENT_TICKS && occupied {
"Ready: waiting for free base cell"
} else {
"Recruiting"
}
}
pub fn new(owner: Players) -> Self {
Self {
durability: 1500,
stress: 0,
owner,
level: 1,
queued_miners: 0,
recruitment_ticks: 0,
waiting_for_departure: false,
}
} }
}
} }
impl Structure for BaseBuilding { impl Structure for BaseBuilding {
fn get_tag(&self) -> char { fn get_tag(&self) -> char {
'B' 'B'
}
fn get_color(&self) -> Color {
match self.owner {
Players::Player => Color::LightBlue,
Players::Enemy => Color::LightRed,
Players::Unclaimed => Color::LightGreen,
} }
}
fn get_level(&self) -> char { fn get_color(&self) -> Color {
(self.level + b'0') as char match self.owner {
} Players::Player => Color::LightBlue,
Players::Enemy => Color::LightRed,
Players::Unclaimed => Color::LightGreen,
}
}
fn get_durability(&self) -> u16 { fn get_level(&self) -> char {
self.durability (self.level + b'0') as char
} }
fn get_max_durability(&self) -> u16 { fn get_durability(&self) -> u16 {
1500 self.durability
} }
fn get_stress(&self) -> u8 { fn get_max_durability(&self) -> u16 {
self.stress 1500
} }
fn get_name(&self) -> &'static str { fn get_stress(&self) -> u8 {
"Base" self.stress
} }
fn get_owner(&self) -> Players { fn get_name(&self) -> &'static str {
self.owner "Base"
} }
fn text(&self) -> Vec<Line<'_>> { fn get_owner(&self) -> Players {
let mut lines: Vec<Line<'_>> = self.base_text(); self.owner
lines.push(Line::from(format!( }
"Recruitment queue: {}",
self.queued_miners fn text(&self) -> Vec<Line<'_>> {
))); let mut lines: Vec<Line<'_>> = self.base_text();
if self.queued_miners > 0 { lines.push(Line::from(format!(
lines.push(Line::from(format!( "Recruitment queue: {}",
"Miner progress: {}%", self.queued_miners
self.recruitment_progress() )));
))); if self.queued_miners > 0 {
lines.push(Line::from(format!(
"Miner progress: {}%",
self.recruitment_progress()
)));
}
lines
} }
lines
}
} }
@@ -1,96 +1,96 @@
use crate::app::states::skirmish_states::{ use crate::app::states::skirmish_states::{
Players, Resources, Players, Resources,
structures::{Ore, Structure}, structures::{Ore, Structure},
units::{Unit, Units}, units::{Unit, Units},
}; };
use ratatui::{ use ratatui::{
style::{Color, Stylize}, style::{Color, Stylize},
text::Line, text::Line,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Mine { pub struct Mine {
owner: Players, owner: Players,
ore: Ore, ore: Ore,
} }
impl Mine { impl Mine {
pub const COST: Resources = Resources { pub const COST: Resources = Resources {
wood: 20, wood: 20,
metal: 10, metal: 10,
};
pub const METAL_PER_CYCLE: u16 = 1;
pub const STAFFED_METAL_PER_CYCLE: u16 = 2;
pub fn new(owner: Players, ore: Ore) -> Option<Self> {
if owner == Players::Unclaimed || ore.get_owner() != Players::Unclaimed {
return None;
}
Some(Self { owner, ore })
}
pub fn get_ore_amount(&self) -> u16 {
self.ore.get_ore_amount()
}
pub fn into_ore(self) -> Ore {
self.ore
}
pub fn produce(&mut self) -> u16 {
self.produce_with_miner(false)
}
pub fn accepts_unit(&self, unit: &Units) -> bool {
matches!(unit, Units::Miner(_)) && unit.get_owner() == self.owner
}
pub fn production_per_cycle(&self, staffed: bool) -> u16 {
let rate: u16 = if staffed {
Self::STAFFED_METAL_PER_CYCLE
} else {
Self::METAL_PER_CYCLE
}; };
rate.min(self.get_ore_amount()) pub const METAL_PER_CYCLE: u16 = 1;
} pub const STAFFED_METAL_PER_CYCLE: u16 = 2;
pub fn produce_with_miner(&mut self, staffed: bool) -> u16 { pub fn new(owner: Players, ore: Ore) -> Option<Self> {
self.ore.extract(self.production_per_cycle(staffed)) if owner == Players::Unclaimed || ore.get_owner() != Players::Unclaimed {
} return None;
}
Some(Self { owner, ore })
}
pub fn get_ore_amount(&self) -> u16 {
self.ore.get_ore_amount()
}
pub fn into_ore(self) -> Ore {
self.ore
}
pub fn produce(&mut self) -> u16 {
self.produce_with_miner(false)
}
pub fn accepts_unit(&self, unit: &Units) -> bool {
matches!(unit, Units::Miner(_)) && unit.get_owner() == self.owner
}
pub fn production_per_cycle(&self, staffed: bool) -> u16 {
let rate: u16 = if staffed {
Self::STAFFED_METAL_PER_CYCLE
} else {
Self::METAL_PER_CYCLE
};
rate.min(self.get_ore_amount())
}
pub fn produce_with_miner(&mut self, staffed: bool) -> u16 {
self.ore.extract(self.production_per_cycle(staffed))
}
} }
impl Structure for Mine { impl Structure for Mine {
fn get_tag(&self) -> char { fn get_tag(&self) -> char {
'M' 'M'
} }
fn get_color(&self) -> Color { fn get_color(&self) -> Color {
self.owner.get_color() self.owner.get_color()
} }
fn get_level(&self) -> char { fn get_level(&self) -> char {
self.ore.get_level() self.ore.get_level()
} }
fn get_stress(&self) -> u8 { fn get_stress(&self) -> u8 {
self.ore.get_stress() self.ore.get_stress()
} }
fn get_durability(&self) -> u16 { fn get_durability(&self) -> u16 {
self.ore.get_durability() self.ore.get_durability()
} }
fn get_max_durability(&self) -> u16 { fn get_max_durability(&self) -> u16 {
self.ore.get_max_durability() self.ore.get_max_durability()
} }
fn get_name(&self) -> &'static str { fn get_name(&self) -> &'static str {
"Mine" "Mine"
} }
fn get_owner(&self) -> Players { fn get_owner(&self) -> Players {
self.owner self.owner
} }
fn text(&self) -> Vec<Line<'_>> { fn text(&self) -> Vec<Line<'_>> {
let mut lines: Vec<Line<'_>> = self.base_text(); let mut lines: Vec<Line<'_>> = self.base_text();
lines.push(Line::from_iter([ lines.push(Line::from_iter([
"Ore: ".gray(), "Ore: ".gray(),
self.get_ore_amount().to_string().cyan(), self.get_ore_amount().to_string().cyan(),
format!("/{}", self.ore.get_max_ore_amount()).gray(), format!("/{}", self.ore.get_max_ore_amount()).gray(),
])); ]));
lines lines
} }
} }
@@ -1,75 +1,75 @@
use crate::app::{ use crate::app::{
states::skirmish_states::{ states::skirmish_states::{
Players, Players,
structures::{Mine, Ore, Structure}, structures::{Mine, Ore, Structure},
}, },
threads::TICK_MS, threads::TICK_MS,
}; };
use ratatui::{style::Color, text::Line}; use ratatui::{style::Color, text::Line};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MineConstruction { pub struct MineConstruction {
mine: Mine, mine: Mine,
ticks: u16, ticks: u16,
} }
impl MineConstruction { impl MineConstruction {
pub const BUILD_TICKS: u16 = 10000_u16.div_ceil(TICK_MS as u16); pub const BUILD_TICKS: u16 = 10000_u16.div_ceil(TICK_MS as u16);
pub fn new(owner: Players, ore: Ore) -> Option<Self> { pub fn new(owner: Players, ore: Ore) -> Option<Self> {
Mine::new(owner, ore).map(|mine: Mine| -> Self { Self { mine, ticks: 0 } }) Mine::new(owner, ore).map(|mine: Mine| -> Self { Self { mine, ticks: 0 } })
} }
pub fn advance(&mut self) -> () { pub fn advance(&mut self) -> () {
self.ticks = self.ticks.saturating_add(1).min(Self::BUILD_TICKS); self.ticks = self.ticks.saturating_add(1).min(Self::BUILD_TICKS);
} }
pub fn is_finished(&self) -> bool { pub fn is_finished(&self) -> bool {
self.ticks == Self::BUILD_TICKS self.ticks == Self::BUILD_TICKS
} }
pub fn progress_percent(&self) -> u16 { pub fn progress_percent(&self) -> u16 {
(u32::from(self.ticks) * 100 / u32::from(Self::BUILD_TICKS)) as u16 (u32::from(self.ticks) * 100 / u32::from(Self::BUILD_TICKS)) as u16
} }
pub fn into_mine(self) -> Option<Mine> { pub fn into_mine(self) -> Option<Mine> {
self.is_finished().then_some(self.mine) self.is_finished().then_some(self.mine)
} }
pub fn cancel(self) -> Ore { pub fn cancel(self) -> Ore {
self.mine.into_ore() self.mine.into_ore()
} }
} }
impl Structure for MineConstruction { impl Structure for MineConstruction {
fn get_tag(&self) -> char { fn get_tag(&self) -> char {
'C' 'C'
} }
fn get_color(&self) -> Color { fn get_color(&self) -> Color {
self.mine.get_color() self.mine.get_color()
} }
fn get_level(&self) -> char { fn get_level(&self) -> char {
self.mine.get_level() self.mine.get_level()
} }
fn get_stress(&self) -> u8 { fn get_stress(&self) -> u8 {
self.mine.get_stress() self.mine.get_stress()
} }
fn get_durability(&self) -> u16 { fn get_durability(&self) -> u16 {
self.mine.get_durability() self.mine.get_durability()
} }
fn get_max_durability(&self) -> u16 { fn get_max_durability(&self) -> u16 {
self.mine.get_max_durability() self.mine.get_max_durability()
} }
fn get_name(&self) -> &'static str { fn get_name(&self) -> &'static str {
"Mine construction" "Mine construction"
} }
fn get_owner(&self) -> Players { fn get_owner(&self) -> Players {
self.mine.get_owner() self.mine.get_owner()
} }
fn text(&self) -> Vec<Line<'_>> { fn text(&self) -> Vec<Line<'_>> {
vec![ vec![
Line::from(format!("Construction: {}%", self.progress_percent())), Line::from(format!("Construction: {}%", self.progress_percent())),
Line::from(format!("Ore: {}", self.mine.get_ore_amount())), Line::from(format!("Ore: {}", self.mine.get_ore_amount())),
] ]
} }
} }
@@ -18,6 +18,6 @@ pub use tunnel::Tunnel;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuildingTerrain { pub enum BuildingTerrain {
Stone, Stone,
Ore, Ore,
} }
@@ -1,100 +1,100 @@
use crate::app::states::skirmish_states::{Players, structures::Structure}; use crate::app::states::skirmish_states::{Players, structures::Structure};
use ratatui::{ use ratatui::{
style::{Color, Stylize}, style::{Color, Stylize},
text::Line, text::Line,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Ore { pub struct Ore {
owner: Players, owner: Players,
ore_amount: u16, ore_amount: u16,
durability: u16, durability: u16,
stress: u8, stress: u8,
level: u8, level: u8,
} }
impl Ore { impl Ore {
pub fn new(owner: Players, level: u8) -> Self { pub fn new(owner: Players, level: u8) -> Self {
Self { Self {
owner, owner,
ore_amount: 5000, ore_amount: 5000,
durability: 1000, durability: 1000,
stress: 0, stress: 0,
level, level,
}
} }
}
pub fn get_ore_amount(&self) -> u16 { pub fn get_ore_amount(&self) -> u16 {
self.ore_amount self.ore_amount
} }
pub fn get_max_ore_amount(&self) -> u16 { pub fn get_max_ore_amount(&self) -> u16 {
5000 5000
} }
pub fn extract(&mut self, amount: u16) -> u16 { pub fn extract(&mut self, amount: u16) -> u16 {
let extracted: u16 = amount.min(self.ore_amount); let extracted: u16 = amount.min(self.ore_amount);
self.ore_amount -= extracted; self.ore_amount -= extracted;
extracted extracted
} }
pub fn set_owner(&mut self, new_owner: Players) -> &mut Self { pub fn set_owner(&mut self, new_owner: Players) -> &mut Self {
self.owner = new_owner; self.owner = new_owner;
self self
} }
} }
impl Structure for Ore { impl Structure for Ore {
fn get_tag(&self) -> char { fn get_tag(&self) -> char {
'O' 'O'
}
fn get_color(&self) -> Color {
match self.owner {
Players::Player => self.owner.get_color(),
Players::Enemy => self.owner.get_color(),
Players::Unclaimed => Color::Indexed(217),
} }
}
fn get_durability(&self) -> u16 { fn get_color(&self) -> Color {
self.durability match self.owner {
} Players::Player => self.owner.get_color(),
Players::Enemy => self.owner.get_color(),
fn get_level(&self) -> char { Players::Unclaimed => Color::Indexed(217),
if self.level != 0 { }
(self.level + b'0') as char
} else {
' '
} }
}
fn get_max_durability(&self) -> u16 { fn get_durability(&self) -> u16 {
1000 self.durability
} }
fn get_name(&self) -> &'static str { fn get_level(&self) -> char {
"Ore" if self.level != 0 {
} (self.level + b'0') as char
} else {
' '
}
}
fn get_owner(&self) -> Players { fn get_max_durability(&self) -> u16 {
self.owner 1000
} }
fn get_stress(&self) -> u8 { fn get_name(&self) -> &'static str {
self.stress "Ore"
} }
fn text(&self) -> Vec<Line<'_>> { fn get_owner(&self) -> Players {
let mut lines: Vec<Line<'_>> = Structure::base_text(self); self.owner
}
lines.push(Line::from_iter([ fn get_stress(&self) -> u8 {
"Ore: ".gray(), self.stress
self.get_ore_amount().to_string().cyan(), }
"/".gray(),
self.get_max_ore_amount().to_string().cyan(),
]));
lines fn text(&self) -> Vec<Line<'_>> {
} let mut lines: Vec<Line<'_>> = Structure::base_text(self);
lines.push(Line::from_iter([
"Ore: ".gray(),
self.get_ore_amount().to_string().cyan(),
"/".gray(),
self.get_max_ore_amount().to_string().cyan(),
]));
lines
}
} }
@@ -3,53 +3,53 @@ use ratatui::style::Color;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Stone { pub struct Stone {
durability: u16, durability: u16,
stress: u8, stress: u8,
} }
impl Stone { impl Stone {
pub fn take_damage(&mut self, damage: u16) { pub fn take_damage(&mut self, damage: u16) {
self.durability = self.durability.saturating_sub(damage); self.durability = self.durability.saturating_sub(damage);
} }
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
durability: 1000, durability: 1000,
stress: 0, stress: 0,
}
} }
}
} }
impl Structure for Stone { impl Structure for Stone {
fn get_tag(&self) -> char { fn get_tag(&self) -> char {
' ' ' '
} }
fn get_color(&self) -> Color { fn get_color(&self) -> Color {
Color::White Color::White
} }
fn get_level(&self) -> char { fn get_level(&self) -> char {
' ' ' '
} }
fn get_durability(&self) -> u16 { fn get_durability(&self) -> u16 {
self.durability self.durability
} }
fn get_max_durability(&self) -> u16 { fn get_max_durability(&self) -> u16 {
1000 1000
} }
fn get_stress(&self) -> u8 { fn get_stress(&self) -> u8 {
self.stress self.stress
} }
fn get_name(&self) -> &'static str { fn get_name(&self) -> &'static str {
"Stone" "Stone"
} }
fn get_owner(&self) -> Players { fn get_owner(&self) -> Players {
Players::Unclaimed Players::Unclaimed
} }
} }
@@ -1,57 +1,57 @@
use crate::app::states::skirmish_states::Players; use crate::app::states::skirmish_states::Players;
use ratatui::{ use ratatui::{
style::{Color, Stylize}, style::{Color, Stylize},
text::Line, text::Line,
}; };
pub trait Structure { pub trait Structure {
fn get_tag(&self) -> char; fn get_tag(&self) -> char;
fn get_color(&self) -> Color; fn get_color(&self) -> Color;
fn get_level(&self) -> char; fn get_level(&self) -> char;
fn get_stress(&self) -> u8; fn get_stress(&self) -> u8;
fn get_durability(&self) -> u16; fn get_durability(&self) -> u16;
fn get_max_durability(&self) -> u16; fn get_max_durability(&self) -> u16;
fn get_name(&self) -> &'static str; fn get_name(&self) -> &'static str;
fn get_owner(&self) -> Players; fn get_owner(&self) -> Players;
fn base_text(&self) -> Vec<Line<'_>> { fn base_text(&self) -> Vec<Line<'_>> {
let durability: u16 = self.get_durability(); let durability: u16 = self.get_durability();
let max_durability: u16 = self.get_max_durability(); let max_durability: u16 = self.get_max_durability();
let durability_percent: u32 = durability as u32 * 100 / max_durability as u32; let durability_percent: u32 = durability as u32 * 100 / max_durability as u32;
let stress: u8 = self.get_stress(); let stress: u8 = self.get_stress();
let level: char = self.get_level(); let level: char = self.get_level();
let mut lines: Vec<Line<'_>> = vec![ let mut lines: Vec<Line<'_>> = vec![
Line::from_iter([ Line::from_iter([
"Durability: ".gray(), "Durability: ".gray(),
durability.to_string().cyan(), durability.to_string().cyan(),
"/".gray(), "/".gray(),
max_durability.to_string().cyan(), max_durability.to_string().cyan(),
" ( ".gray(), " ( ".gray(),
durability_percent.to_string().cyan(), durability_percent.to_string().cyan(),
"% )".gray(), "% )".gray(),
]), ]),
Line::from_iter(["Stress: ".gray(), stress.to_string().cyan(), "%".gray()]), Line::from_iter(["Stress: ".gray(), stress.to_string().cyan(), "%".gray()]),
]; ];
if level != ' ' { if level != ' ' {
lines.push(Line::from_iter([ lines.push(Line::from_iter([
"Level: ".gray(), "Level: ".gray(),
level.to_string().cyan(), level.to_string().cyan(),
])); ]));
}
lines
} }
lines fn text(&self) -> Vec<Line<'_>> {
} self.base_text()
}
fn text(&self) -> Vec<Line<'_>> {
self.base_text()
}
} }
@@ -1,100 +1,100 @@
use crate::app::states::skirmish_states::{ use crate::app::states::skirmish_states::{
Players, Players,
structures::{BaseBuilding, Mine, MineConstruction, Ore, Stone, Structure, Tunnel}, structures::{BaseBuilding, Mine, MineConstruction, Ore, Stone, Structure, Tunnel},
units::Units, units::Units,
}; };
use ratatui::{ use ratatui::{
style::{Color, Stylize}, style::{Color, Stylize},
text::Line, text::Line,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Structures { pub enum Structures {
Base(BaseBuilding), Base(BaseBuilding),
Tunnel(Tunnel), Tunnel(Tunnel),
Stone(Stone), Stone(Stone),
Ore(Ore), Ore(Ore),
Mine(Mine), Mine(Mine),
MineConstruction(MineConstruction), MineConstruction(MineConstruction),
} }
impl Structures { impl Structures {
pub fn can_enter(&self, unit: &Units) -> bool { pub fn can_enter(&self, unit: &Units) -> bool {
match self { match self {
Self::Base(_) | Self::Tunnel(_) => true, Self::Base(_) | Self::Tunnel(_) => true,
Self::Mine(mine) => mine.accepts_unit(unit), Self::Mine(mine) => mine.accepts_unit(unit),
_ => false, _ => false,
}
} }
} pub fn build_mine(&mut self, owner: Players) -> bool {
pub fn build_mine(&mut self, owner: Players) -> bool { if let Self::Ore(ore) = *self
if let Self::Ore(ore) = *self && let Some(mine) = Mine::new(owner, ore)
&& let Some(mine) = Mine::new(owner, ore) {
{ *self = Self::Mine(mine);
*self = Self::Mine(mine); return true;
return true; }
false
} }
false
}
#[inline] #[inline]
fn s(&self) -> &dyn Structure { fn s(&self) -> &dyn Structure {
match self { match self {
Self::Base(b) => b, Self::Base(b) => b,
Self::Tunnel(t) => t, Self::Tunnel(t) => t,
Self::Stone(s) => s, Self::Stone(s) => s,
Self::Ore(o) => o, Self::Ore(o) => o,
Self::Mine(mine) => mine, Self::Mine(mine) => mine,
Self::MineConstruction(site) => site, Self::MineConstruction(site) => site,
}
} }
}
} }
impl Structure for Structures { impl Structure for Structures {
fn get_color(&self) -> Color { fn get_color(&self) -> Color {
self.s().get_color() self.s().get_color()
} }
fn get_tag(&self) -> char { fn get_tag(&self) -> char {
self.s().get_tag() self.s().get_tag()
} }
fn get_level(&self) -> char { fn get_level(&self) -> char {
self.s().get_level() self.s().get_level()
} }
fn get_name(&self) -> &'static str { fn get_name(&self) -> &'static str {
self.s().get_name() self.s().get_name()
} }
fn get_durability(&self) -> u16 { fn get_durability(&self) -> u16 {
self.s().get_durability() self.s().get_durability()
} }
fn get_max_durability(&self) -> u16 { fn get_max_durability(&self) -> u16 {
self.s().get_max_durability() self.s().get_max_durability()
} }
fn get_stress(&self) -> u8 { fn get_stress(&self) -> u8 {
self.s().get_stress() self.s().get_stress()
} }
fn get_owner(&self) -> Players { fn get_owner(&self) -> Players {
self.s().get_owner() self.s().get_owner()
} }
fn text(&self) -> Vec<Line<'_>> { fn text(&self) -> Vec<Line<'_>> {
match self.s().get_owner() { match self.s().get_owner() {
Players::Unclaimed => self.s().text(), Players::Unclaimed => self.s().text(),
Players::Player | Players::Enemy => { Players::Player | Players::Enemy => {
let mut lines: Vec<Line<'_>> = self.s().text(); let mut lines: Vec<Line<'_>> = self.s().text();
lines.push(Line::from_iter([ lines.push(Line::from_iter([
"Owner: ".gray(), "Owner: ".gray(),
self.s().get_owner().get_span(), self.s().get_owner().get_span(),
])); ]));
lines lines
} }
}
} }
}
} }
@@ -3,55 +3,55 @@ use ratatui::style::Color;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Tunnel { pub struct Tunnel {
durability: u16, durability: u16,
stress: u8, stress: u8,
roof_support: bool, roof_support: bool,
rail: bool, rail: bool,
lamp: bool, lamp: bool,
} }
impl Tunnel { impl Tunnel {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
durability: 500, durability: 500,
stress: 25, stress: 25,
roof_support: false, roof_support: false,
rail: false, rail: false,
lamp: false, lamp: false,
}
} }
}
} }
impl Structure for Tunnel { impl Structure for Tunnel {
fn get_tag(&self) -> char { fn get_tag(&self) -> char {
'T' 'T'
} }
fn get_color(&self) -> Color { fn get_color(&self) -> Color {
Color::Gray Color::Gray
} }
fn get_level(&self) -> char { fn get_level(&self) -> char {
' ' ' '
} }
fn get_durability(&self) -> u16 { fn get_durability(&self) -> u16 {
self.durability self.durability
} }
fn get_max_durability(&self) -> u16 { fn get_max_durability(&self) -> u16 {
500 500
} }
fn get_stress(&self) -> u8 { fn get_stress(&self) -> u8 {
self.stress self.stress
} }
fn get_name(&self) -> &'static str { fn get_name(&self) -> &'static str {
"Tunnel" "Tunnel"
} }
fn get_owner(&self) -> Players { fn get_owner(&self) -> Players {
Players::Unclaimed Players::Unclaimed
} }
} }
+50 -50
View File
@@ -3,71 +3,71 @@ use std::collections::VecDeque;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct DiggingTask { pub struct DiggingTask {
path: VecDeque<(usize, usize)>, path: VecDeque<(usize, usize)>,
progress: f32, progress: f32,
tick_increase: f32, tick_increase: f32,
total_steps: usize, total_steps: usize,
} }
impl DiggingTask { impl DiggingTask {
pub fn new(path: VecDeque<(usize, usize)>, tick_increase: f32) -> Self { pub fn new(path: VecDeque<(usize, usize)>, tick_increase: f32) -> Self {
Self { Self {
total_steps: path.len(), total_steps: path.len(),
path, path,
progress: 0.0, progress: 0.0,
tick_increase, tick_increase,
}
} }
}
pub fn pop_front(&mut self) { pub fn pop_front(&mut self) {
self.path.pop_front(); self.path.pop_front();
self.progress = 0.0; self.progress = 0.0;
}
pub fn from_moving(task: MovingTask, tick_increase: f32) -> Self {
Self {
path: task.get_path().clone(),
progress: 0.0,
tick_increase,
total_steps: task.get_total_steps(),
} }
}
pub fn current_target(&self) -> Option<(usize, usize)> { pub fn from_moving(task: MovingTask, tick_increase: f32) -> Self {
self.path.front().copied() Self {
} path: task.get_path().clone(),
progress: 0.0,
tick_increase,
total_steps: task.get_total_steps(),
}
}
pub fn current_target(&self) -> Option<(usize, usize)> {
self.path.front().copied()
}
} }
impl Task for DiggingTask { impl Task for DiggingTask {
fn get_icon(&self) -> char { fn get_icon(&self) -> char {
'⛏' '⛏'
} }
fn get_path(&self) -> &VecDeque<(usize, usize)> { fn get_path(&self) -> &VecDeque<(usize, usize)> {
&self.path &self.path
} }
fn get_path_front_coords(&self) -> &(usize, usize) { fn get_path_front_coords(&self) -> &(usize, usize) {
self.path.front().map_or(&(0, 0), |c| c) self.path.front().map_or(&(0, 0), |c| c)
} }
fn get_path_back_coords(&self) -> &(usize, usize) { fn get_path_back_coords(&self) -> &(usize, usize) {
self.path.back().map_or(&(0, 0), |c| c) self.path.back().map_or(&(0, 0), |c| c)
} }
fn get_progress(&self) -> f32 { fn get_progress(&self) -> f32 {
self.progress self.progress
} }
fn get_total_steps(&self) -> usize { fn get_total_steps(&self) -> usize {
self.total_steps self.total_steps
} }
fn progress(&mut self) { fn progress(&mut self) {
self.progress = (self.progress + self.tick_increase).clamp(0.0, 100.0); self.progress = (self.progress + self.tick_increase).clamp(0.0, 100.0);
} }
fn is_finished(&self) -> bool { fn is_finished(&self) -> bool {
self.progress >= 100.0 self.progress >= 100.0
} }
} }
+48 -48
View File
@@ -1,6 +1,6 @@
use crate::app::{ use crate::app::{
states::skirmish_states::tasks::{DiggingTask, Task}, states::skirmish_states::tasks::{DiggingTask, Task},
threads::TICK_MS, threads::TICK_MS,
}; };
use std::collections::VecDeque; use std::collections::VecDeque;
@@ -8,68 +8,68 @@ const MOVEMENT_TICKS: u16 = 3000_u16.div_ceil(TICK_MS as u16);
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct MovingTask { pub struct MovingTask {
path: VecDeque<(usize, usize)>, path: VecDeque<(usize, usize)>,
ticks: u16, ticks: u16,
total_steps: usize, total_steps: usize,
} }
impl MovingTask { impl MovingTask {
pub fn new(path: VecDeque<(usize, usize)>) -> Self { pub fn new(path: VecDeque<(usize, usize)>) -> Self {
Self { Self {
total_steps: path.len(), total_steps: path.len(),
path, path,
ticks: 0, ticks: 0,
}
} }
}
pub fn from_digging(task: DiggingTask) -> Self { pub fn from_digging(task: DiggingTask) -> Self {
Self { Self {
total_steps: task.get_total_steps(), total_steps: task.get_total_steps(),
path: task.get_path().clone(), path: task.get_path().clone(),
ticks: 0, ticks: 0,
}
} }
}
pub fn pop_front(&mut self) { pub fn pop_front(&mut self) {
self.path.pop_front(); self.path.pop_front();
self.ticks = 0; self.ticks = 0;
} }
pub fn get_movement_progress(&self) -> Option<u16> { pub fn get_movement_progress(&self) -> Option<u16> {
(self.ticks > 0).then(|| self.ticks * 100 / MOVEMENT_TICKS) (self.ticks > 0).then(|| self.ticks * 100 / MOVEMENT_TICKS)
} }
} }
impl Task for MovingTask { impl Task for MovingTask {
fn get_icon(&self) -> char { fn get_icon(&self) -> char {
'→' '→'
} }
fn get_path(&self) -> &VecDeque<(usize, usize)> { fn get_path(&self) -> &VecDeque<(usize, usize)> {
&self.path &self.path
} }
fn get_path_front_coords(&self) -> &(usize, usize) { fn get_path_front_coords(&self) -> &(usize, usize) {
self.path.front().unwrap_or(&(0, 0)) self.path.front().unwrap_or(&(0, 0))
} }
fn get_path_back_coords(&self) -> &(usize, usize) { fn get_path_back_coords(&self) -> &(usize, usize) {
self.path.back().unwrap_or(&(0, 0)) self.path.back().unwrap_or(&(0, 0))
} }
fn get_progress(&self) -> f32 { fn get_progress(&self) -> f32 {
f32::from(self.ticks) * 100.0 / f32::from(MOVEMENT_TICKS) f32::from(self.ticks) * 100.0 / f32::from(MOVEMENT_TICKS)
} }
fn get_total_steps(&self) -> usize { fn get_total_steps(&self) -> usize {
self.total_steps self.total_steps
} }
fn progress(&mut self) { fn progress(&mut self) {
self.ticks = (self.ticks + 1).min(MOVEMENT_TICKS); self.ticks = (self.ticks + 1).min(MOVEMENT_TICKS);
} }
fn is_finished(&self) -> bool { fn is_finished(&self) -> bool {
self.ticks == MOVEMENT_TICKS self.ticks == MOVEMENT_TICKS
} }
} }
@@ -1,28 +1,28 @@
use std::collections::VecDeque; use std::collections::VecDeque;
pub trait Task { pub trait Task {
fn get_icon(&self) -> char; fn get_icon(&self) -> char;
fn get_path(&self) -> &VecDeque<(usize, usize)>; fn get_path(&self) -> &VecDeque<(usize, usize)>;
fn get_path_front_coords(&self) -> &(usize, usize); fn get_path_front_coords(&self) -> &(usize, usize);
fn get_path_back_coords(&self) -> &(usize, usize); fn get_path_back_coords(&self) -> &(usize, usize);
fn get_progress(&self) -> f32; fn get_progress(&self) -> f32;
fn get_total_steps(&self) -> usize; fn get_total_steps(&self) -> usize;
fn get_total_progress(&self) -> f32 { fn get_total_progress(&self) -> f32 {
let total: usize = self.get_total_steps(); let total: usize = self.get_total_steps();
if total == 0 { if total == 0 {
return 100.0; return 100.0;
}
let completed: usize = total.saturating_sub(self.get_path().len());
((completed as f32 * 100.0 + self.get_progress()) / total as f32).clamp(0.0, 100.0)
} }
let completed: usize = total.saturating_sub(self.get_path().len());
((completed as f32 * 100.0 + self.get_progress()) / total as f32).clamp(0.0, 100.0)
}
fn progress(&mut self); fn progress(&mut self);
fn is_finished(&self) -> bool; fn is_finished(&self) -> bool;
} }
@@ -3,58 +3,58 @@ use std::collections::VecDeque;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum Tasks { pub enum Tasks {
Digging(DiggingTask), Digging(DiggingTask),
Moving(MovingTask), Moving(MovingTask),
} }
impl Tasks { impl Tasks {
#[inline] #[inline]
fn t(&self) -> &dyn Task { fn t(&self) -> &dyn Task {
match self { match self {
Self::Digging(d) => d, Self::Digging(d) => d,
Self::Moving(m) => m, Self::Moving(m) => m,
}
} }
}
#[inline] #[inline]
fn t_mut(&mut self) -> &mut dyn Task { fn t_mut(&mut self) -> &mut dyn Task {
match self { match self {
Self::Digging(d) => d, Self::Digging(d) => d,
Self::Moving(m) => m, Self::Moving(m) => m,
}
} }
}
} }
impl Task for Tasks { impl Task for Tasks {
fn get_icon(&self) -> char { fn get_icon(&self) -> char {
self.t().get_icon() self.t().get_icon()
} }
fn get_path(&self) -> &VecDeque<(usize, usize)> { fn get_path(&self) -> &VecDeque<(usize, usize)> {
self.t().get_path() self.t().get_path()
} }
fn get_path_front_coords(&self) -> &(usize, usize) { fn get_path_front_coords(&self) -> &(usize, usize) {
self.t().get_path_front_coords() self.t().get_path_front_coords()
} }
fn get_path_back_coords(&self) -> &(usize, usize) { fn get_path_back_coords(&self) -> &(usize, usize) {
self.t().get_path_back_coords() self.t().get_path_back_coords()
} }
fn get_progress(&self) -> f32 { fn get_progress(&self) -> f32 {
self.t().get_progress() self.t().get_progress()
} }
fn get_total_steps(&self) -> usize { fn get_total_steps(&self) -> usize {
self.t().get_total_steps() self.t().get_total_steps()
} }
fn is_finished(&self) -> bool { fn is_finished(&self) -> bool {
self.t().is_finished() self.t().is_finished()
} }
fn progress(&mut self) { fn progress(&mut self) {
self.t_mut().progress(); self.t_mut().progress();
} }
} }
+101 -102
View File
@@ -1,133 +1,132 @@
use ratatui::{style::Stylize, text::Line}; use ratatui::{style::Stylize, text::Line};
use crate::app::states::skirmish_states::{ use crate::app::states::skirmish_states::{
Players, Resources, Players, Resources,
tasks::{Task, Tasks}, tasks::{Task, Tasks},
units::Unit, units::Unit,
}; };
use std::collections::VecDeque; use std::collections::VecDeque;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct MinerUnit { pub struct MinerUnit {
owner: Players, owner: Players,
hp: u16, hp: u16,
can_dig: bool, can_dig: bool,
digging_power: f32, digging_power: f32,
coords: (usize, usize), coords: (usize, usize),
tasks: VecDeque<Tasks>, tasks: VecDeque<Tasks>,
} }
impl MinerUnit { impl MinerUnit {
pub const RECRUIT_COST: Resources = Resources { pub const RECRUIT_COST: Resources = Resources {
wood: 10, wood: 10,
metal: 15, metal: 15,
}; };
pub fn new(owner: Players, coords: (usize, usize)) -> Self { pub fn new(owner: Players, coords: (usize, usize)) -> Self {
Self { Self {
owner, owner,
hp: 100, hp: 100,
can_dig: true, can_dig: true,
digging_power: 0.55, digging_power: 0.55,
coords, coords,
tasks: VecDeque::new(), tasks: VecDeque::new(),
} }
}
fn col_to_letters(&self, col: usize) -> String {
let mut col: usize = col + 1;
let mut letters: Vec<char> = Vec::new();
while col > 0 {
letters.push((b'A' + ((col - 1) % 26) as u8) as char);
col = (col - 1) / 26;
} }
letters.iter().rev().collect() fn col_to_letters(&self, col: usize) -> String {
} let mut col: usize = col + 1;
let mut letters: Vec<char> = Vec::new();
fn display_coords(&self, coords: &(usize, usize)) -> String { while col > 0 {
format!("{}{}", self.col_to_letters(coords.0), coords.1) letters.push((b'A' + ((col - 1) % 26) as u8) as char);
} col = (col - 1) / 26;
}
letters.iter().rev().collect()
}
fn display_coords(&self, coords: &(usize, usize)) -> String {
format!("{}{}", self.col_to_letters(coords.0), coords.1)
}
} }
impl Unit for MinerUnit { impl Unit for MinerUnit {
fn get_owner(&self) -> Players { fn get_owner(&self) -> Players {
self.owner self.owner
} }
fn get_tag(&self) -> char { fn get_tag(&self) -> char {
'M' 'M'
} }
fn get_name(&self) -> &'static str { fn get_name(&self) -> &'static str {
"Miner" "Miner"
} }
fn get_hp(&self) -> u16 { fn get_hp(&self) -> u16 {
self.hp self.hp
} }
fn get_max_hp(&self) -> u16 { fn get_max_hp(&self) -> u16 {
100 100
} }
fn get_can_dig(&self) -> bool { fn get_can_dig(&self) -> bool {
self.can_dig self.can_dig
} }
fn get_can_build(&self) -> bool { fn get_can_build(&self) -> bool {
true true
} }
fn get_digging_power(&self) -> f32 { fn get_digging_power(&self) -> f32 {
self.digging_power self.digging_power
} }
fn get_coords(&self) -> (usize, usize) { fn get_coords(&self) -> (usize, usize) {
self.coords self.coords
} }
fn set_coords(&mut self, coords: (usize, usize)) { fn set_coords(&mut self, coords: (usize, usize)) {
self.coords = coords; self.coords = coords;
} }
fn get_ref_tasks(&self) -> &VecDeque<Tasks> { fn get_ref_tasks(&self) -> &VecDeque<Tasks> {
&self.tasks &self.tasks
} }
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks> { fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks> {
&mut self.tasks &mut self.tasks
} }
fn get_tasks_formatted(&self) -> Vec<Line<'_>> { fn get_tasks_formatted(&self) -> Vec<Line<'_>> {
Vec::from( Vec::from(
self self.tasks
.tasks .iter()
.iter() .enumerate()
.enumerate() .flat_map(|(i, task): (usize, &Tasks)| -> [Line<'_>; 2] {
.flat_map(|(i, task): (usize, &Tasks)| -> [Line<'_>; 2] { [
[ Line::from_iter([
Line::from_iter([ format!("{}. ", i + 1).gray(),
format!("{}. ", i + 1).gray(), task.get_icon().gray(),
task.get_icon().gray(), " ".gray(),
" ".gray(), self.display_coords(task.get_path_front_coords()).green(),
self.display_coords(task.get_path_front_coords()).green(), " -> ".gray(),
" -> ".gray(), self.display_coords(task.get_path_back_coords()).yellow(),
self.display_coords(task.get_path_back_coords()).yellow(), ]),
]), Line::from(format!(
Line::from(format!( " Step: {}% | Total: {}%",
" Step: {}% | Total: {}%", task.get_progress() as u16,
task.get_progress() as u16, task.get_total_progress() as u16,
task.get_total_progress() as u16, ))
)) .cyan(),
.cyan(), ]
] })
}) .collect::<Vec<Line<'_>>>(),
.collect::<Vec<Line<'_>>>(), )
) }
}
fn set_task(&mut self, task: Tasks) { fn set_task(&mut self, task: Tasks) {
self.tasks.push_back(task); self.tasks.push_back(task);
} }
} }
@@ -1,90 +1,90 @@
use crate::app::states::skirmish_states::{Players, tasks::Tasks}; use crate::app::states::skirmish_states::{Players, tasks::Tasks};
use ratatui::{ use ratatui::{
style::Stylize, style::Stylize,
text::{Line, Span}, text::{Line, Span},
}; };
use std::collections::VecDeque; use std::collections::VecDeque;
pub trait Unit { pub trait Unit {
fn is_unit(&self) -> bool { fn is_unit(&self) -> bool {
true true
}
fn get_owner(&self) -> Players;
fn get_tag(&self) -> char;
fn get_name(&self) -> &'static str;
fn get_hp(&self) -> u16;
fn get_max_hp(&self) -> u16;
fn get_can_dig(&self) -> bool;
fn get_can_build(&self) -> bool {
false
}
fn get_digging_power(&self) -> f32;
fn get_coords(&self) -> (usize, usize);
fn set_coords(&mut self, coords: (usize, usize));
fn get_ref_tasks(&self) -> &VecDeque<Tasks>;
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks>;
fn set_task(&mut self, task: Tasks);
fn get_tasks_formatted(&self) -> Vec<Line<'_>>;
fn base_text(&self) -> Vec<Line<'_>> {
let owner: Span<'_> = self.get_owner().get_span();
let hp: u16 = self.get_hp();
let max_hp: u16 = self.get_max_hp();
let hp_percent: u16 = hp * 100 / max_hp;
let can_dig: bool = self.get_can_dig();
let digging_power: f32 = self.get_digging_power();
let have_any_tasks: bool = self.get_ref_tasks().is_empty();
let tasks: Vec<Line<'_>> = self.get_tasks_formatted();
let mut lines: Vec<Line<'_>> = Vec::from([
Line::from_iter(["Owner: ".gray(), owner]),
Line::from_iter([
"HP: ".gray(),
hp.green(),
"/".gray(),
max_hp.green(),
" ( ".gray(),
hp_percent.green(),
"% )".gray(),
]),
]);
if can_dig {
lines.push(Line::from_iter([
"Digging power: ".gray(),
digging_power.green(),
]));
} }
if self.get_can_build() { fn get_owner(&self) -> Players;
lines.push(Line::from("Can build: Yes").green());
fn get_tag(&self) -> char;
fn get_name(&self) -> &'static str;
fn get_hp(&self) -> u16;
fn get_max_hp(&self) -> u16;
fn get_can_dig(&self) -> bool;
fn get_can_build(&self) -> bool {
false
} }
if !have_any_tasks { fn get_digging_power(&self) -> f32;
lines.push(Line::from("Tasks Queue: ".gray()));
for task in tasks { fn get_coords(&self) -> (usize, usize);
lines.push(task);
} fn set_coords(&mut self, coords: (usize, usize));
fn get_ref_tasks(&self) -> &VecDeque<Tasks>;
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks>;
fn set_task(&mut self, task: Tasks);
fn get_tasks_formatted(&self) -> Vec<Line<'_>>;
fn base_text(&self) -> Vec<Line<'_>> {
let owner: Span<'_> = self.get_owner().get_span();
let hp: u16 = self.get_hp();
let max_hp: u16 = self.get_max_hp();
let hp_percent: u16 = hp * 100 / max_hp;
let can_dig: bool = self.get_can_dig();
let digging_power: f32 = self.get_digging_power();
let have_any_tasks: bool = self.get_ref_tasks().is_empty();
let tasks: Vec<Line<'_>> = self.get_tasks_formatted();
let mut lines: Vec<Line<'_>> = Vec::from([
Line::from_iter(["Owner: ".gray(), owner]),
Line::from_iter([
"HP: ".gray(),
hp.green(),
"/".gray(),
max_hp.green(),
" ( ".gray(),
hp_percent.green(),
"% )".gray(),
]),
]);
if can_dig {
lines.push(Line::from_iter([
"Digging power: ".gray(),
digging_power.green(),
]));
}
if self.get_can_build() {
lines.push(Line::from("Can build: Yes").green());
}
if !have_any_tasks {
lines.push(Line::from("Tasks Queue: ".gray()));
for task in tasks {
lines.push(task);
}
}
lines
} }
lines fn text(&self) -> Vec<Line<'_>> {
} self.base_text()
}
fn text(&self) -> Vec<Line<'_>> {
self.base_text()
}
} }
+117 -121
View File
@@ -1,158 +1,154 @@
use crate::app::states::skirmish_states::{ use crate::app::states::skirmish_states::{
Players, Players,
tasks::Tasks, tasks::Tasks,
units::{MinerUnit, Unit}, units::{MinerUnit, Unit},
}; };
use ratatui::text::Line; use ratatui::text::Line;
use std::collections::VecDeque; use std::collections::VecDeque;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum Units { pub enum Units {
Miner(MinerUnit), Miner(MinerUnit),
} }
impl Units { impl Units {
#[inline] #[inline]
pub fn u(&self) -> &dyn Unit { pub fn u(&self) -> &dyn Unit {
match self { match self {
Self::Miner(m) => m, Self::Miner(m) => m,
}
} }
}
#[inline] #[inline]
pub fn u_mut(&mut self) -> &mut dyn Unit { pub fn u_mut(&mut self) -> &mut dyn Unit {
match self { match self {
Self::Miner(m) => m, Self::Miner(m) => m,
}
} }
}
} }
impl Unit for Units { impl Unit for Units {
fn get_can_build(&self) -> bool { fn get_can_build(&self) -> bool {
self.u().get_can_build() self.u().get_can_build()
} }
fn get_tag(&self) -> char { fn get_tag(&self) -> char {
self.u().get_tag() self.u().get_tag()
} }
fn get_name(&self) -> &'static str { fn get_name(&self) -> &'static str {
self.u().get_name() self.u().get_name()
} }
fn get_owner(&self) -> Players { fn get_owner(&self) -> Players {
self.u().get_owner() self.u().get_owner()
} }
fn get_hp(&self) -> u16 { fn get_hp(&self) -> u16 {
self.u().get_hp() self.u().get_hp()
} }
fn get_max_hp(&self) -> u16 { fn get_max_hp(&self) -> u16 {
self.u().get_max_hp() self.u().get_max_hp()
} }
fn get_can_dig(&self) -> bool { fn get_can_dig(&self) -> bool {
self.u().get_can_dig() self.u().get_can_dig()
} }
fn get_digging_power(&self) -> f32 { fn get_digging_power(&self) -> f32 {
self.u().get_digging_power() self.u().get_digging_power()
} }
fn get_coords(&self) -> (usize, usize) { fn get_coords(&self) -> (usize, usize) {
self.u().get_coords() self.u().get_coords()
} }
fn set_coords(&mut self, coords: (usize, usize)) { fn set_coords(&mut self, coords: (usize, usize)) {
self.u_mut().set_coords(coords); self.u_mut().set_coords(coords);
} }
fn get_ref_tasks(&self) -> &VecDeque<Tasks> { fn get_ref_tasks(&self) -> &VecDeque<Tasks> {
self.u().get_ref_tasks() self.u().get_ref_tasks()
} }
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks> { fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks> {
self.u_mut().get_mut_tasks() self.u_mut().get_mut_tasks()
} }
fn get_tasks_formatted(&self) -> Vec<Line<'_>> { fn get_tasks_formatted(&self) -> Vec<Line<'_>> {
self.u().get_tasks_formatted() self.u().get_tasks_formatted()
} }
fn set_task(&mut self, task: Tasks) { fn set_task(&mut self, task: Tasks) {
self.u_mut().set_task(task); self.u_mut().set_task(task);
} }
} }
impl Unit for Option<Units> { impl Unit for Option<Units> {
fn get_can_build(&self) -> bool { fn get_can_build(&self) -> bool {
self self.as_ref()
.as_ref() .is_some_and(|unit: &Units| -> bool { unit.get_can_build() })
.is_some_and(|unit: &Units| -> bool { unit.get_can_build() }) }
} fn is_unit(&self) -> bool {
fn is_unit(&self) -> bool { self.as_ref().map_or(false, |u| u.is_unit())
self.as_ref().map_or(false, |u| u.is_unit())
}
fn get_tag(&self) -> char {
self.as_ref().map_or(' ', |u| u.get_tag())
}
fn get_name(&self) -> &'static str {
self.as_ref().map_or("", |u| u.get_name())
}
fn get_owner(&self) -> Players {
self.as_ref().map_or(Players::Unclaimed, |u| u.get_owner())
}
fn get_hp(&self) -> u16 {
self.as_ref().map_or(0, |u| u.get_hp())
}
fn get_max_hp(&self) -> u16 {
self.as_ref().map_or(0, |u| u.get_max_hp())
}
fn get_can_dig(&self) -> bool {
self.as_ref().map_or(false, |u| u.get_can_dig())
}
fn get_digging_power(&self) -> f32 {
self.as_ref().map_or(0.0, |u| u.get_digging_power())
}
fn get_coords(&self) -> (usize, usize) {
self.as_ref().map_or((0, 0), |u| u.get_coords())
}
fn set_coords(&mut self, coords: (usize, usize)) {
if let Some(unit) = self.as_mut() {
unit.set_coords(coords);
} }
}
fn get_ref_tasks(&self) -> &VecDeque<Tasks> { fn get_tag(&self) -> char {
self self.as_ref().map_or(' ', |u| u.get_tag())
.as_ref() }
.expect("attempted to get referenced tasks from `None`")
.get_ref_tasks()
}
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks> { fn get_name(&self) -> &'static str {
self self.as_ref().map_or("", |u| u.get_name())
.as_mut() }
.expect("attempted to get mutable tasks from `None`")
.get_mut_tasks()
}
fn get_tasks_formatted(&self) -> Vec<Line<'_>> { fn get_owner(&self) -> Players {
self self.as_ref().map_or(Players::Unclaimed, |u| u.get_owner())
.as_ref() }
.map_or(Vec::new(), |u| u.get_tasks_formatted())
}
fn set_task(&mut self, task: Tasks) { fn get_hp(&self) -> u16 {
self.as_mut().map_or((), |u| u.set_task(task)) self.as_ref().map_or(0, |u| u.get_hp())
} }
fn get_max_hp(&self) -> u16 {
self.as_ref().map_or(0, |u| u.get_max_hp())
}
fn get_can_dig(&self) -> bool {
self.as_ref().map_or(false, |u| u.get_can_dig())
}
fn get_digging_power(&self) -> f32 {
self.as_ref().map_or(0.0, |u| u.get_digging_power())
}
fn get_coords(&self) -> (usize, usize) {
self.as_ref().map_or((0, 0), |u| u.get_coords())
}
fn set_coords(&mut self, coords: (usize, usize)) {
if let Some(unit) = self.as_mut() {
unit.set_coords(coords);
}
}
fn get_ref_tasks(&self) -> &VecDeque<Tasks> {
self.as_ref()
.expect("attempted to get referenced tasks from `None`")
.get_ref_tasks()
}
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks> {
self.as_mut()
.expect("attempted to get mutable tasks from `None`")
.get_mut_tasks()
}
fn get_tasks_formatted(&self) -> Vec<Line<'_>> {
self.as_ref()
.map_or(Vec::new(), |u| u.get_tasks_formatted())
}
fn set_task(&mut self, task: Tasks) {
self.as_mut().map_or((), |u| u.set_task(task))
}
} }
+43 -43
View File
@@ -3,56 +3,56 @@ use ratatui::text::{Line, Span};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
pub enum ZoomLevel { pub enum ZoomLevel {
ZoomedIn, ZoomedIn,
Default, Default,
ZoomedOut, ZoomedOut,
} }
pub enum CellSizes { pub enum CellSizes {
Width, Width,
Height, Height,
} }
impl ZoomLevel { impl ZoomLevel {
pub fn get_cell_size(&self, cell_size: CellSizes) -> usize { pub fn get_cell_size(&self, cell_size: CellSizes) -> usize {
match (cell_size, self) { match (cell_size, self) {
(CellSizes::Width, ZoomLevel::ZoomedIn) => 13, (CellSizes::Width, ZoomLevel::ZoomedIn) => 13,
(CellSizes::Width, ZoomLevel::Default) => 9, (CellSizes::Width, ZoomLevel::Default) => 9,
(CellSizes::Width, ZoomLevel::ZoomedOut) => 5, (CellSizes::Width, ZoomLevel::ZoomedOut) => 5,
(CellSizes::Height, ZoomLevel::ZoomedIn) => 7, (CellSizes::Height, ZoomLevel::ZoomedIn) => 7,
(CellSizes::Height, ZoomLevel::Default) => 5, (CellSizes::Height, ZoomLevel::Default) => 5,
(CellSizes::Height, ZoomLevel::ZoomedOut) => 3, (CellSizes::Height, ZoomLevel::ZoomedOut) => 3,
}
} }
}
pub fn get_cell_text_area<'a>( pub fn get_cell_text_area<'a>(
&self, &self,
tag: char, tag: char,
structure_level: char, structure_level: char,
unit: Span<'a>, unit: Span<'a>,
) -> Vec<Line<'a>> { ) -> Vec<Line<'a>> {
match self { match self {
ZoomLevel::ZoomedIn => vec![ ZoomLevel::ZoomedIn => vec![
Line::from(vec![ Line::from(vec![
Span::raw(format!(" {} ", structure_level)), Span::raw(format!(" {} ", structure_level)),
unit, unit,
Span::raw(" "), Span::raw(" "),
]), ]),
Line::from(format!(" ")), Line::from(format!(" ")),
Line::from(format!(" {} ", tag)), Line::from(format!(" {} ", tag)),
Line::from(format!(" ")), Line::from(format!(" ")),
Line::from(format!(" ")), Line::from(format!(" ")),
], ],
ZoomLevel::Default => vec![ ZoomLevel::Default => vec![
Line::from(vec![ Line::from(vec![
Span::raw(format!(" {} ", structure_level)), Span::raw(format!(" {} ", structure_level)),
unit, unit,
Span::raw(" "), Span::raw(" "),
]), ]),
Line::from(format!(" {} ", tag)), Line::from(format!(" {} ", tag)),
Line::from(format!(" ")), Line::from(format!(" ")),
], ],
ZoomLevel::ZoomedOut => vec![Line::from(format!(" {} ", tag))], ZoomLevel::ZoomedOut => vec![Line::from(format!(" {} ", tag))],
}
} }
}
} }
+135 -135
View File
@@ -1,185 +1,185 @@
use clap::ValueEnum; use clap::ValueEnum;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use rodio::{ use rodio::{
Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, cpal::BufferSize, Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, cpal::BufferSize,
decoder::LoopedDecoder, source::Amplify, decoder::LoopedDecoder, source::Amplify,
}; };
use std::{ use std::{
collections::HashMap, collections::HashMap,
fs::read, fs::read,
io::{BufReader, Cursor}, io::{BufReader, Cursor},
path::{Path, PathBuf}, path::{Path, PathBuf},
process::exit, process::exit,
sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, mpsc::Receiver}, sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, mpsc::Receiver},
}; };
#[derive(Debug)] #[derive(Debug)]
pub enum AudioCmd { pub enum AudioCmd {
Mute, Mute,
VolumeUp, VolumeUp,
VolumeDown, VolumeDown,
ChangePart(SoundrackParts), ChangePart(SoundrackParts),
ChangeSoundtrack(Soundtrack), ChangeSoundtrack(Soundtrack),
} }
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, ValueEnum)] #[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, ValueEnum)]
pub enum Soundtrack { pub enum Soundtrack {
Default, Default,
} }
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy)] #[derive(Debug, Eq, PartialEq, Hash, Clone, Copy)]
pub enum SoundrackParts { pub enum SoundrackParts {
Calm, Calm,
Buildup, Buildup,
Assault, Assault,
Outro, Outro,
} }
type SoundtrackCache = HashMap<SoundrackParts, Vec<u8>>; type SoundtrackCache = HashMap<SoundrackParts, Vec<u8>>;
static PRELOADED_SOUNDTRACKS: Lazy<RwLock<HashMap<Soundtrack, Arc<SoundtrackCache>>>> = static PRELOADED_SOUNDTRACKS: Lazy<RwLock<HashMap<Soundtrack, Arc<SoundtrackCache>>>> =
Lazy::new(|| { Lazy::new(|| {
let mut map: HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>> = HashMap::new(); let mut map: HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>> = HashMap::new();
map.insert( map.insert(
Soundtrack::Default, Soundtrack::Default,
Arc::new(load_soundtrack_folder("default")), Arc::new(load_soundtrack_folder("default")),
); );
RwLock::new(map) RwLock::new(map)
}); });
fn load_soundtrack_folder(folder: &str) -> SoundtrackCache { fn load_soundtrack_folder(folder: &str) -> SoundtrackCache {
let soundtrack_root: PathBuf = std::env::current_exe() let soundtrack_root: PathBuf = std::env::current_exe()
.ok() .ok()
.and_then(|executable: PathBuf| -> Option<PathBuf> { .and_then(|executable: PathBuf| -> Option<PathBuf> {
executable executable
.parent() .parent()
.map(|directory: &Path| -> PathBuf { directory.join("soundtracks") }) .map(|directory: &Path| -> PathBuf { directory.join("soundtracks") })
}) })
.filter(|directory: &PathBuf| -> bool { directory.is_dir() }) .filter(|directory: &PathBuf| -> bool { directory.is_dir() })
.unwrap_or_else(|| -> PathBuf { PathBuf::from("soundtracks") }); .unwrap_or_else(|| -> PathBuf { PathBuf::from("soundtracks") });
let base: PathBuf = soundtrack_root.join(folder); let base: PathBuf = soundtrack_root.join(folder);
let mut cache: HashMap<SoundrackParts, Vec<u8>> = HashMap::new(); let mut cache: HashMap<SoundrackParts, Vec<u8>> = HashMap::new();
macro_rules! load { macro_rules! load {
($part:ident, $file:expr) => { ($part:ident, $file:expr) => {
let path: PathBuf = base.join($file); let path: PathBuf = base.join($file);
cache.insert( cache.insert(
SoundrackParts::$part, SoundrackParts::$part,
read(&path).expect(&format!("cannot read {}", path.display())), read(&path).expect(&format!("cannot read {}", path.display())),
); );
}; };
} }
load!(Calm, "calm.ogg"); // calm.ogg load!(Calm, "calm.ogg"); // calm.ogg
load!(Buildup, "test.ogg"); // buildup.ogg load!(Buildup, "test.ogg"); // buildup.ogg
load!(Assault, "test.ogg"); // assault.ogg load!(Assault, "test.ogg"); // assault.ogg
load!(Outro, "test.ogg"); // outro.ogg load!(Outro, "test.ogg"); // outro.ogg
cache cache
} }
pub fn load_audio( pub fn load_audio(
soundtrack: &Soundtrack, soundtrack: &Soundtrack,
part: &SoundrackParts, part: &SoundrackParts,
) -> Amplify<LoopedDecoder<BufReader<Cursor<Vec<u8>>>>> { ) -> Amplify<LoopedDecoder<BufReader<Cursor<Vec<u8>>>>> {
{ {
let guard: RwLockReadGuard<'_, HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>>> = let guard: RwLockReadGuard<'_, HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>>> =
PRELOADED_SOUNDTRACKS PRELOADED_SOUNDTRACKS
.read() .read()
.map_err(|e| { .map_err(|e| {
eprintln!("RwLock poisoned: {}", e); eprintln!("RwLock poisoned: {}", e);
exit(1); exit(1);
}) })
.expect("lock issue"); .expect("lock issue");
if let Some(soundtrack_cache) = guard.get(soundtrack) { if let Some(soundtrack_cache) = guard.get(soundtrack) {
let data: &Vec<u8> = soundtrack_cache.get(part).expect("part missing in cache"); let data: &Vec<u8> = soundtrack_cache.get(part).expect("part missing in cache");
return Decoder::new_looped(BufReader::new(Cursor::new(data.clone()))) return Decoder::new_looped(BufReader::new(Cursor::new(data.clone())))
.expect("decoder issue") .expect("decoder issue")
.amplify(0.20); .amplify(0.20);
} }
}
{
let mut guard: RwLockWriteGuard<
'_,
HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>>,
> = PRELOADED_SOUNDTRACKS
.write()
.map_err(|e| {
eprintln!("RwLock poisoned: {}", e);
exit(1);
})
.expect("lock issue");
if let Some(soundtrack_cache) = guard.get(soundtrack) {
let data: &Vec<u8> = soundtrack_cache.get(part).expect("part missing in cache");
return Decoder::new_looped(BufReader::new(Cursor::new(data.clone())))
.expect("decoder issue")
.amplify(0.20);
} }
let folder_name: &str = match soundtrack { {
Soundtrack::Default => "default", let mut guard: RwLockWriteGuard<
}; '_,
HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>>,
> = PRELOADED_SOUNDTRACKS
.write()
.map_err(|e| {
eprintln!("RwLock poisoned: {}", e);
exit(1);
})
.expect("lock issue");
let new_cache: Arc<HashMap<SoundrackParts, Vec<u8>>> = if let Some(soundtrack_cache) = guard.get(soundtrack) {
Arc::new(load_soundtrack_folder(folder_name)); let data: &Vec<u8> = soundtrack_cache.get(part).expect("part missing in cache");
let data: &Vec<u8> = new_cache.get(&part).expect("part missing after load"); return Decoder::new_looped(BufReader::new(Cursor::new(data.clone())))
.expect("decoder issue")
.amplify(0.20);
}
guard.insert(*soundtrack, Arc::clone(&new_cache)); let folder_name: &str = match soundtrack {
Soundtrack::Default => "default",
};
Decoder::new_looped(BufReader::new(Cursor::new(data.clone()))) let new_cache: Arc<HashMap<SoundrackParts, Vec<u8>>> =
.expect("decoder issue") Arc::new(load_soundtrack_folder(folder_name));
.amplify(0.20)
} let data: &Vec<u8> = new_cache.get(&part).expect("part missing after load");
guard.insert(*soundtrack, Arc::clone(&new_cache));
Decoder::new_looped(BufReader::new(Cursor::new(data.clone())))
.expect("decoder issue")
.amplify(0.20)
}
} }
pub fn handle_audio(rx: Receiver<AudioCmd>, mute: bool, soundtrack: Soundtrack) { pub fn handle_audio(rx: Receiver<AudioCmd>, mute: bool, soundtrack: Soundtrack) {
let mut handle: MixerDeviceSink = DeviceSinkBuilder::from_default_device() let mut handle: MixerDeviceSink = DeviceSinkBuilder::from_default_device()
.expect("get default device") .expect("get default device")
.with_buffer_size(BufferSize::Fixed(4096)) .with_buffer_size(BufferSize::Fixed(4096))
.open_sink_or_fallback() .open_sink_or_fallback()
.expect("open device audio stream"); .expect("open device audio stream");
handle.log_on_drop(false); handle.log_on_drop(false);
let player: Player = Player::connect_new(&handle.mixer()); let player: Player = Player::connect_new(&handle.mixer());
let mut volume: f32 = player.volume(); let mut volume: f32 = player.volume();
player.set_volume(if mute { 0.0 } else { volume }); player.set_volume(if mute { 0.0 } else { volume });
let mut current_soundtrack: Soundtrack = soundtrack; let mut current_soundtrack: Soundtrack = soundtrack;
let mut current_part: SoundrackParts = SoundrackParts::Calm; let mut current_part: SoundrackParts = SoundrackParts::Calm;
player.append(load_audio(&current_soundtrack, &current_part)); player.append(load_audio(&current_soundtrack, &current_part));
for cmd in rx { for cmd in rx {
match cmd { match cmd {
AudioCmd::Mute => { AudioCmd::Mute => {
player.set_volume(if player.volume() == 0.0 { volume } else { 0.0 }); player.set_volume(if player.volume() == 0.0 { volume } else { 0.0 });
} }
AudioCmd::VolumeUp => { AudioCmd::VolumeUp => {
volume = (volume + 0.1).min(1.0); volume = (volume + 0.1).min(1.0);
player.set_volume(volume); player.set_volume(volume);
} }
AudioCmd::VolumeDown => { AudioCmd::VolumeDown => {
volume = (volume - 0.1).max(0.0); volume = (volume - 0.1).max(0.0);
player.set_volume(volume); player.set_volume(volume);
} }
AudioCmd::ChangePart(part) => { AudioCmd::ChangePart(part) => {
current_part = part; current_part = part;
player.append(load_audio(&current_soundtrack, &current_part)); player.append(load_audio(&current_soundtrack, &current_part));
player.skip_one(); player.skip_one();
} }
AudioCmd::ChangeSoundtrack(st) => { AudioCmd::ChangeSoundtrack(st) => {
current_soundtrack = st; current_soundtrack = st;
player.append(load_audio(&current_soundtrack, &current_part)); player.append(load_audio(&current_soundtrack, &current_part));
player.skip_one(); player.skip_one();
} }
}
} }
}
} }
+14 -14
View File
@@ -2,20 +2,20 @@ use ratatui::crossterm::event::{Event, KeyEvent, read};
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
pub fn handle_ct_events(input_tx: Sender<KeyEvent>, resize_tx: Sender<(u16, u16)>) { pub fn handle_ct_events(input_tx: Sender<KeyEvent>, resize_tx: Sender<(u16, u16)>) {
loop { loop {
match read() { match read() {
Ok(Event::Key(k)) => { Ok(Event::Key(k)) => {
if input_tx.send(k).is_err() { if input_tx.send(k).is_err() {
break; break;
}
}
Ok(Event::Resize(cols, rows)) => {
if resize_tx.send((cols, rows)).is_err() {
break;
}
}
Ok(_) => {}
Err(_) => continue,
} }
}
Ok(Event::Resize(cols, rows)) => {
if resize_tx.send((cols, rows)).is_err() {
break;
}
}
Ok(_) => {}
Err(_) => continue,
} }
}
} }
+12 -12
View File
@@ -1,21 +1,21 @@
use std::{ use std::{
sync::mpsc::Sender, sync::mpsc::Sender,
thread, thread,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
pub const TICK_MS: u8 = 33; pub const TICK_MS: u8 = 33;
pub fn handle_tick_event(tx: Sender<()>) { pub fn handle_tick_event(tx: Sender<()>) {
let interval: Duration = Duration::from_millis(TICK_MS as u64); let interval: Duration = Duration::from_millis(TICK_MS as u64);
loop { loop {
if tx.send(()).is_err() { if tx.send(()).is_err() {
break; break;
} }
let elapsed: Duration = Instant::now().elapsed(); let elapsed: Duration = Instant::now().elapsed();
if interval > elapsed { if interval > elapsed {
thread::sleep(interval - elapsed); thread::sleep(interval - elapsed);
}
} }
}
} }
+15 -15
View File
@@ -1,27 +1,27 @@
use crate::app::{ use crate::app::{
App, App,
views::{default_view, main_menu_view, skirmish_view}, views::{default_view, main_menu_view, skirmish_view},
}; };
use clap::ValueEnum; use clap::ValueEnum;
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
pub enum View { pub enum View {
MainMenu, MainMenu,
Skirmish, Skirmish,
PerkDecks, PerkDecks,
SkillsConfig, SkillsConfig,
} }
impl Widget for &mut App { impl Widget for &mut App {
fn render(self, area: Rect, buf: &mut Buffer) fn render(self, area: Rect, buf: &mut Buffer)
where where
Self: Sized, Self: Sized,
{ {
match self.view { match self.view {
View::MainMenu => main_menu_view(self, area, buf), View::MainMenu => main_menu_view(self, area, buf),
View::Skirmish => skirmish_view(self, area, buf), View::Skirmish => skirmish_view(self, area, buf),
_ => default_view(area, buf), _ => default_view(area, buf),
}
} }
}
} }
+26 -26
View File
@@ -1,40 +1,40 @@
use crate::app::{ use crate::app::{
keybindings::{Action, count_largest_group}, keybindings::{Action, count_largest_group},
widgets::KeybindingsWidget, widgets::KeybindingsWidget,
}; };
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Alignment, Constraint, Layout, Rect}, layout::{Alignment, Constraint, Layout, Rect},
style::Stylize, style::Stylize,
widgets::{Block, Borders, Paragraph, Widget}, widgets::{Block, Borders, Paragraph, Widget},
}; };
const ACTIONS: &[Action] = &[Action::Quit, Action::Quit2, Action::Esc]; const ACTIONS: &[Action] = &[Action::Quit, Action::Quit2, Action::Esc];
fn default_view_layout(area: Rect) -> [Rect; 2] { fn default_view_layout(area: Rect) -> [Rect; 2] {
Layout::vertical([ Layout::vertical([
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(count_largest_group(&ACTIONS) + 2), Constraint::Length(count_largest_group(&ACTIONS) + 2),
]) ])
.areas(area) .areas(area)
} }
pub fn default_view(area: Rect, buf: &mut Buffer) { pub fn default_view(area: Rect, buf: &mut Buffer) {
let [main_area, keybindings_area] = default_view_layout(area); let [main_area, keybindings_area] = default_view_layout(area);
{ {
Paragraph::new("Work in progress") Paragraph::new("Work in progress")
.alignment(Alignment::Center) .alignment(Alignment::Center)
.yellow() .yellow()
.block( .block(
Block::new() Block::new()
.gray() .gray()
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT), .borders(Borders::LEFT | Borders::TOP | Borders::RIGHT),
) )
.render(main_area, buf); .render(main_area, buf);
} }
{ {
KeybindingsWidget::new(ACTIONS, keybindings_area.height).render(keybindings_area, buf); KeybindingsWidget::new(ACTIONS, keybindings_area.height).render(keybindings_area, buf);
} }
} }
+74 -74
View File
@@ -1,100 +1,100 @@
use crate::app::{ use crate::app::{
App, View, App, View,
helpers::main_menu_option_helper, helpers::main_menu_option_helper,
keybindings::{Action, count_largest_group}, keybindings::{Action, count_largest_group},
widgets::KeybindingsWidget, widgets::KeybindingsWidget,
}; };
use clap::ValueEnum; use clap::ValueEnum;
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Alignment, Constraint, Layout, Rect}, layout::{Alignment, Constraint, Layout, Rect},
style::Stylize, style::Stylize,
text::Line, text::Line,
widgets::{Block, Borders, Paragraph, Widget}, widgets::{Block, Borders, Paragraph, Widget},
}; };
const ACTIONS: &[Action] = &[ const ACTIONS: &[Action] = &[
Action::Up, Action::Up,
Action::Down, Action::Down,
Action::Space, Action::Space,
Action::VolumeUp, Action::VolumeUp,
Action::VolumeDown, Action::VolumeDown,
Action::Mute, Action::Mute,
Action::Quit, Action::Quit,
Action::Quit2, Action::Quit2,
]; ];
fn main_menu_layout(area: Rect) -> [Rect; 2] { fn main_menu_layout(area: Rect) -> [Rect; 2] {
Layout::vertical([ Layout::vertical([
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(count_largest_group(&ACTIONS) + 2), Constraint::Length(count_largest_group(&ACTIONS) + 2),
]) ])
.areas(area) .areas(area)
} }
pub fn main_menu_view(app: &App, area: Rect, buf: &mut Buffer) { pub fn main_menu_view(app: &App, area: Rect, buf: &mut Buffer) {
let Some(states) = app.states_ref() else { let Some(states) = app.states_ref() else {
return; return;
}; };
let [main_menu_area, keybindings_area] = main_menu_layout(area); let [main_menu_area, keybindings_area] = main_menu_layout(area);
Block::new() Block::new()
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT) .borders(Borders::LEFT | Borders::TOP | Borders::RIGHT)
.render(main_menu_area, buf); .render(main_menu_area, buf);
let main_menu_areas: Vec<Rect> = main_menu_area.layout_vec(&Layout::vertical([ let main_menu_areas: Vec<Rect> = main_menu_area.layout_vec(&Layout::vertical([
Constraint::Percentage(50), Constraint::Percentage(50),
Constraint::Percentage(50), Constraint::Percentage(50),
])); ]));
{ {
let title_area: Rect = main_menu_areas[0].centered_vertically(Constraint::Percentage(50)); let title_area: Rect = main_menu_areas[0].centered_vertically(Constraint::Percentage(50));
let title_text: String = vec![ let title_text: String = vec![
r" __ __ _ _____ _ ", r" __ __ _ _____ _ ",
r"/ / /\ \ \__ _ _ __ (_)_ __ /__ \_ _ _ __ _ __ ___| |___ ", r"/ / /\ \ \__ _ _ __ (_)_ __ /__ \_ _ _ __ _ __ ___| |___ ",
r"\ \/ \/ / _` | '__| | | '_ \ / /\/ | | | '_ \| '_ \ / _ \ / __|", r"\ \/ \/ / _` | '__| | | '_ \ / /\/ | | | '_ \| '_ \ / _ \ / __|",
r" \ /\ / (_| | | | | | | | / / | |_| | | | | | | | __/ \__ \", r" \ /\ / (_| | | | | | | | / / | |_| | | | | | | | __/ \__ \",
r" \/ \/ \__,_|_| |_|_| |_| \/ \__,_|_| |_|_| |_|\___|_|___/", r" \/ \/ \__,_|_| |_|_| |_| \/ \__,_|_| |_|_| |_|\___|_|___/",
] ]
.join("\n"); .join("\n");
Paragraph::new(title_text) Paragraph::new(title_text)
.alignment(Alignment::Center) .alignment(Alignment::Center)
.yellow() .yellow()
.block(Block::new().gray().borders(Borders::LEFT | Borders::RIGHT)) .block(Block::new().gray().borders(Borders::LEFT | Borders::RIGHT))
.render(title_area, buf); .render(title_area, buf);
} }
{ {
let options_area: Rect = main_menu_areas[1]; let options_area: Rect = main_menu_areas[1];
let lines: Vec<Line<'_>> = View::value_variants() let lines: Vec<Line<'_>> = View::value_variants()
.iter() .iter()
.enumerate() .enumerate()
.filter(|(_, v)| v != &&View::MainMenu) .filter(|(_, v)| v != &&View::MainMenu)
.map(|(i, view)| { .map(|(i, view)| {
let view_string: String = main_menu_option_helper(format!("{:?}", view)); let view_string: String = main_menu_option_helper(format!("{:?}", view));
let styled: Line<'_> = if states.main_menu.selected_view == i { let styled: Line<'_> = if states.main_menu.selected_view == i {
Line::from_iter(["> ".cyan(), view_string.yellow()]).yellow() Line::from_iter(["> ".cyan(), view_string.yellow()]).yellow()
} else { } else {
Line::from(format!(" {view_string}")).white() Line::from(format!(" {view_string}")).white()
}; };
styled styled
}) })
.collect(); .collect();
Paragraph::new(lines) Paragraph::new(lines)
.alignment(Alignment::Center) .alignment(Alignment::Center)
.green() .green()
.block(Block::new().gray().borders(Borders::LEFT | Borders::RIGHT)) .block(Block::new().gray().borders(Borders::LEFT | Borders::RIGHT))
.render(options_area, buf); .render(options_area, buf);
} }
{ {
KeybindingsWidget::new(ACTIONS, keybindings_area.height).render(keybindings_area, buf); KeybindingsWidget::new(ACTIONS, keybindings_area.height).render(keybindings_area, buf);
} }
} }
+252 -248
View File
@@ -1,265 +1,269 @@
use crate::app::{ use crate::app::{
App, App,
helpers::block_title_helper, helpers::block_title_helper,
keybindings::{Action, available_skirmish_actions}, keybindings::{Action, available_skirmish_actions},
states::skirmish_states::{ states::skirmish_states::{
BoardState, Players, BoardState, Players,
resources::{WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_INTERVAL_MS}, resources::{WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_INTERVAL_MS},
structures::{Mine, Structure, Structures}, structures::{Mine, Structure, Structures},
units::MinerUnit, units::MinerUnit,
}, },
widgets::{BoardWidget, CellWidget, KeybindingsWidget, SidePanelWidget}, widgets::{BoardWidget, CellWidget, KeybindingsWidget, SidePanelWidget},
}; };
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Alignment, Constraint, Layout, Margin, Rect}, layout::{Alignment, Constraint, Layout, Margin, Rect},
style::{Color, Stylize}, style::{Color, Stylize},
text::Line, text::Line,
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
}; };
pub fn skirmish_layout(area: Rect) -> [Rect; 3] { pub fn skirmish_layout(area: Rect) -> [Rect; 3] {
Layout::vertical([ Layout::vertical([
Constraint::Length(4), Constraint::Length(4),
Constraint::Fill(1), Constraint::Fill(1),
// Four movement rows plus borders keep the board geometry stable across modes. // Four movement rows plus borders keep the board geometry stable across modes.
Constraint::Length(6), Constraint::Length(6),
]) ])
.areas(area) .areas(area)
} }
pub fn skirmish_main_area_layout(area: Rect, side_panel: bool) -> [Rect; 2] { pub fn skirmish_main_area_layout(area: Rect, side_panel: bool) -> [Rect; 2] {
let constraint: [Constraint; 2] = if side_panel { let constraint: [Constraint; 2] = if side_panel {
[Constraint::Fill(1), Constraint::Length(39)] [Constraint::Fill(1), Constraint::Length(39)]
} else { } else {
[Constraint::Percentage(100), Constraint::Percentage(0)] [Constraint::Percentage(100), Constraint::Percentage(0)]
}; };
Layout::horizontal(constraint).areas(area) Layout::horizontal(constraint).areas(area)
} }
pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) { pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
let Some(states) = app.states_ref() else { let Some(states) = app.states_ref() else {
return; return;
};
let board: &BoardState = &states.skirmish.board;
let [title_area, main_area, keybindings_area] = skirmish_layout(area);
{
let lines: Vec<Line<'_>> = Vec::from_iter([
Line::raw("War in Tunnels").yellow(),
Line::from_iter([
format!(
"Wood: {} (+{}/{}s) | ",
states.skirmish.player_resources.wood,
WOOD_DELIVERY_AMOUNT,
WOOD_DELIVERY_INTERVAL_MS / 1000
),
format!("Metal: {} | ", states.skirmish.player_resources.metal),
format!("Supply Limit: {}/{} | ", 10, states.settings.supply_limit),
format!(
"Skills points: {} ({}/{}) | ",
1, 20, states.settings.skill_points_limit
),
format!("Perk Deck: {}/9 | ", 5),
format!("Tick: {}", states.skirmish.turn_counter),
]),
]);
Paragraph::new(lines)
.alignment(Alignment::Left)
.block(
Block::new()
.gray()
.padding(Padding {
left: 1,
right: 1,
top: 0,
bottom: 1,
})
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT),
)
.render(title_area, buf);
}
{
let main_block: Block = Block::new()
.gray()
.title(block_title_helper(
&[
("Skirmish".to_string(), Color::Magenta),
("Map".to_string(), Color::Green),
],
Some(" - "),
))
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT);
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 cells_area: Rect = board_area.inner(Margin {
horizontal: 1,
vertical: 1,
});
// .centered(
// Constraint::Length(
// (board.cell_width * board.cols) as u16,
// ),
// Constraint::Length(
// (board.cell_height * board.rows) as u16,
// ),
// );
BoardWidget::new(&board).render(cells_area, buf);
if states.skirmish.side_panel {
let (row, col) = board.get_focused_cell().get_coords();
let mut is_unit_marked: bool = false;
if let Some((first_row, first_col)) = board.marked_cells.marked_cells.get(0) {
is_unit_marked = first_row == &row && first_col == &col;
}
SidePanelWidget::new(
(row, col),
&board.get_ref_cell(row, col).get_structure(),
&board.get_ref_cell(row, col).get_ref_option_unit(),
is_unit_marked,
)
.render(side_panel_area, buf);
}
}
{
let actions: Vec<Action> = available_skirmish_actions(&states.skirmish);
let descriptions: [(Action, &'static str); 11] = [
(
Action::Enter,
if states.skirmish.recruitment_view {
"Queue Miner"
} else {
"Build Mine"
},
),
(
Action::Recruitment,
if states.skirmish.recruitment_view {
"Close recruitment"
} else {
"Recruit"
},
),
(
Action::Delete,
if states.skirmish.building_view {
"Cancel construction"
} else {
"Cancel marking"
},
),
(
Action::Space,
if board.marked_cells.marking_cells {
"Confirm route"
} else {
"Select miner"
},
),
(
Action::Esc,
if states.skirmish.recruitment_view {
"Close recruitment"
} else if states.skirmish.building_view {
"Close buildings"
} else if board.marked_cells.marking_cells {
"Cancel route"
} else {
"Menu"
},
),
(
Action::Building,
if states.skirmish.building_view {
"Close buildings"
} else {
"Build"
},
),
(Action::Tab, "Details"),
(Action::ScrollUp, "Scroll"),
(Action::ScrollDown, "Scroll"),
(Action::ScrollLeft, "Scroll"),
(Action::ScrollRight, "Scroll"),
];
KeybindingsWidget::with_descriptions(&actions, keybindings_area.height, &descriptions)
.render(keybindings_area, buf);
}
if states.skirmish.building_view {
let popup: Rect = main_area.centered(Constraint::Length(64), Constraint::Length(14));
let coords: (usize, usize) = board.get_focused_cell().get_coords();
let structure: Structures = board.get_ref_cell(coords.0, coords.1).get_structure();
let status: String = if let Structures::MineConstruction(site) = structure
&& site.get_owner() == Players::Player
{
format!(
"Construction: {}%\n{}\nDelete: cancel and refund full cost",
site.progress_percent(),
if board.has_adjacent_builder(coords, Players::Player) {
"Builder working"
} else {
"Paused: adjacent friendly builder required"
}
)
} else if matches!(structure, Structures::Mine(_)) && structure.get_owner() == Players::Player {
"Mine completed".to_string()
} else if states
.skirmish
.can_start_mine_construction(coords, Players::Player)
{
"Ready to build: Enter".to_string()
} else {
"Construction unavailable: check site, builder and resources".to_string()
}; };
Clear.render(popup, buf);
Paragraph::new(format!("Mine\nCost: {} Wood + {} Metal | Build time: 10s\nRequires: neutral Ore + adjacent friendly builder\nProduces: 1 Metal/s (2 with a Miner inside)\n{status}", Mine::COST.wood, Mine::COST.metal)) let board: &BoardState = &states.skirmish.board;
let [title_area, main_area, keybindings_area] = skirmish_layout(area);
{
let lines: Vec<Line<'_>> = Vec::from_iter([
Line::raw("War in Tunnels").yellow(),
Line::from_iter([
format!(
"Wood: {} (+{}/{}s) | ",
states.skirmish.player_resources.wood,
WOOD_DELIVERY_AMOUNT,
WOOD_DELIVERY_INTERVAL_MS / 1000
),
format!("Metal: {} | ", states.skirmish.player_resources.metal),
format!("Supply Limit: {}/{} | ", 10, states.settings.supply_limit),
format!(
"Skills points: {} ({}/{}) | ",
1, 20, states.settings.skill_points_limit
),
format!("Perk Deck: {}/9 | ", 5),
format!("Tick: {}", states.skirmish.turn_counter),
]),
]);
Paragraph::new(lines)
.alignment(Alignment::Left)
.block(
Block::new()
.gray()
.padding(Padding {
left: 1,
right: 1,
top: 0,
bottom: 1,
})
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT),
)
.render(title_area, buf);
}
{
let main_block: Block = Block::new()
.gray()
.title(block_title_helper(
&[
("Skirmish".to_string(), Color::Magenta),
("Map".to_string(), Color::Green),
],
Some(" - "),
))
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT);
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 cells_area: Rect = board_area.inner(Margin {
horizontal: 1,
vertical: 1,
});
// .centered(
// Constraint::Length(
// (board.cell_width * board.cols) as u16,
// ),
// Constraint::Length(
// (board.cell_height * board.rows) as u16,
// ),
// );
BoardWidget::new(&board).render(cells_area, buf);
if states.skirmish.side_panel {
let (row, col) = board.get_focused_cell().get_coords();
let mut is_unit_marked: bool = false;
if let Some((first_row, first_col)) = board.marked_cells.marked_cells.get(0) {
is_unit_marked = first_row == &row && first_col == &col;
}
SidePanelWidget::new(
(row, col),
&board.get_ref_cell(row, col).get_structure(),
&board.get_ref_cell(row, col).get_ref_option_unit(),
is_unit_marked,
)
.render(side_panel_area, buf);
}
}
{
let actions: Vec<Action> = available_skirmish_actions(&states.skirmish);
let descriptions: [(Action, &'static str); 11] = [
(
Action::Enter,
if states.skirmish.recruitment_view {
"Queue Miner"
} else {
"Build Mine"
},
),
(
Action::Recruitment,
if states.skirmish.recruitment_view {
"Close recruitment"
} else {
"Recruit"
},
),
(
Action::Delete,
if states.skirmish.building_view {
"Cancel construction"
} else {
"Cancel marking"
},
),
(
Action::Space,
if board.marked_cells.marking_cells {
"Confirm route"
} else {
"Select miner"
},
),
(
Action::Esc,
if states.skirmish.recruitment_view {
"Close recruitment"
} else if states.skirmish.building_view {
"Close buildings"
} else if board.marked_cells.marking_cells {
"Cancel route"
} else {
"Menu"
},
),
(
Action::Building,
if states.skirmish.building_view {
"Close buildings"
} else {
"Build"
},
),
(Action::Tab, "Details"),
(Action::ScrollUp, "Scroll"),
(Action::ScrollDown, "Scroll"),
(Action::ScrollLeft, "Scroll"),
(Action::ScrollRight, "Scroll"),
];
KeybindingsWidget::with_descriptions(&actions, keybindings_area.height, &descriptions)
.render(keybindings_area, buf);
}
if states.skirmish.building_view {
let popup: Rect = main_area.centered(Constraint::Length(64), Constraint::Length(14));
let coords: (usize, usize) = board.get_focused_cell().get_coords();
let structure: Structures = board.get_ref_cell(coords.0, coords.1).get_structure();
let status: String = if let Structures::MineConstruction(site) = structure
&& site.get_owner() == Players::Player
{
format!(
"Construction: {}%\n{}\nDelete: cancel and refund full cost",
site.progress_percent(),
if board.has_adjacent_builder(coords, Players::Player) {
"Builder working"
} else {
"Paused: adjacent friendly builder required"
}
)
} else if matches!(structure, Structures::Mine(_))
&& structure.get_owner() == Players::Player
{
"Mine completed".to_string()
} else if states
.skirmish
.can_start_mine_construction(coords, Players::Player)
{
"Ready to build: Enter".to_string()
} else {
"Construction unavailable: check site, builder and resources".to_string()
};
Clear.render(popup, buf);
Paragraph::new(format!("Mine\nCost: {} Wood + {} Metal | Build time: 10s\nRequires: neutral Ore + adjacent friendly builder\nProduces: 1 Metal/s (2 with a Miner inside)\n{status}", Mine::COST.wood, Mine::COST.metal))
.block(Block::bordered().title("Buildings").padding(Padding::uniform(1))) .block(Block::bordered().title("Buildings").padding(Padding::uniform(1)))
.render(popup, buf); .render(popup, buf);
} }
if states.skirmish.recruitment_view { if states.skirmish.recruitment_view {
let popup: Rect = main_area.centered(Constraint::Length(64), Constraint::Length(14)); let popup: Rect = main_area.centered(Constraint::Length(64), Constraint::Length(14));
let coords: (usize, usize) = board.get_focused_cell().get_coords(); let coords: (usize, usize) = board.get_focused_cell().get_coords();
let cell: &CellWidget = board.get_ref_cell(coords.0, coords.1); let cell: &CellWidget = board.get_ref_cell(coords.0, coords.1);
let text: String = if let Structures::Base(base) = cell.get_structure() let text: String = if let Structures::Base(base) = cell.get_structure()
&& base.get_owner() == Players::Player && base.get_owner() == Players::Player
{ {
let status: &'static str = base.recruitment_status(cell.get_ref_option_unit().is_some()); let status: &'static str =
let action: &'static str = if states.skirmish.can_recruit_miner(coords, Players::Player) { base.recruitment_status(cell.get_ref_option_unit().is_some());
"Enter: queue Miner" let action: &'static str = if states.skirmish.can_recruit_miner(coords, Players::Player)
} else { {
"Insufficient resources or queue full" "Enter: queue Miner"
}; } else {
format!( "Insufficient resources or queue full"
"Miner\nCost: {} Wood + {} Metal | Recruit time: 10s\nQueued Miners: {} (includes active)\nCurrent progress: {}%\n{status}\n{action}", };
MinerUnit::RECRUIT_COST.wood, format!(
MinerUnit::RECRUIT_COST.metal, "Miner\nCost: {} Wood + {} Metal | Recruit time: 10s\nQueued Miners: {} (includes active)\nCurrent progress: {}%\n{status}\n{action}",
base.queued_miners(), MinerUnit::RECRUIT_COST.wood,
base.recruitment_progress() MinerUnit::RECRUIT_COST.metal,
) base.queued_miners(),
} else { base.recruitment_progress()
"Select your Base to recruit Miners".to_string() )
}; } else {
Clear.render(popup, buf); "Select your Base to recruit Miners".to_string()
Paragraph::new(text) };
.block( Clear.render(popup, buf);
Block::bordered() Paragraph::new(text)
.title("Recruitment") .block(
.padding(Padding::uniform(1)), Block::bordered()
) .title("Recruitment")
.render(popup, buf); .padding(Padding::uniform(1)),
} )
.render(popup, buf);
}
} }
+29 -30
View File
@@ -1,46 +1,45 @@
use crate::app::states::skirmish_states::BoardState; use crate::app::states::skirmish_states::BoardState;
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Constraint, Layout, Rect}, layout::{Constraint, Layout, Rect},
widgets::Widget, widgets::Widget,
}; };
use std::rc::Rc; use std::rc::Rc;
pub struct BoardWidget<'a> { pub struct BoardWidget<'a> {
state: &'a BoardState, state: &'a BoardState,
} }
impl<'a> BoardWidget<'a> { impl<'a> BoardWidget<'a> {
pub fn new(state: &'a BoardState) -> Self { pub fn new(state: &'a BoardState) -> Self {
Self { state } Self { state }
} }
} }
impl Widget for BoardWidget<'_> { impl Widget for BoardWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let horizontal: Rc<[Rect]> = let horizontal: Rc<[Rect]> =
Layout::horizontal(vec![ Layout::horizontal(vec![
Constraint::Length(self.state.cell_width as u16); Constraint::Length(self.state.cell_width as u16);
self.state.cols self.state.cols
]) ])
.split(area); .split(area);
for (col_idx, col_area) in horizontal.iter().enumerate() { for (col_idx, col_area) in horizontal.iter().enumerate() {
let vertical: Rc<[Rect]> = let vertical: Rc<[Rect]> =
Layout::vertical(vec![ Layout::vertical(vec![
Constraint::Length(self.state.cell_height as u16); Constraint::Length(self.state.cell_height as u16);
self.state.rows self.state.rows
]) ])
.split(*col_area); .split(*col_area);
for (row_idx, cell_area) in vertical.iter().enumerate() { for (row_idx, cell_area) in vertical.iter().enumerate() {
self self.state
.state .cells
.cells .get(row_idx + self.state.vertical_offset.get_value())
.get(row_idx + self.state.vertical_offset.get_value()) .and_then(|rows| rows.get(col_idx + self.state.horizontal_offset.get_value()))
.and_then(|rows| rows.get(col_idx + self.state.horizontal_offset.get_value())) .map(|cell| cell.clone().render(*cell_area, buf));
.map(|cell| cell.clone().render(*cell_area, buf)); }
} }
} }
}
} }
+142 -143
View File
@@ -1,166 +1,165 @@
use crate::app::states::skirmish_states::{ use crate::app::states::skirmish_states::{
ZoomLevel, ZoomLevel,
structures::{Stone, Structure, Structures}, structures::{Stone, Structure, Structures},
tasks::Tasks, tasks::Tasks,
units::{Unit, Units}, units::{Unit, Units},
}; };
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Alignment, Rect}, layout::{Alignment, Rect},
style::{Color, Style, Stylize}, style::{Color, Style, Stylize},
text::{Line, Span, ToSpan}, text::{Line, Span, ToSpan},
widgets::{Block, BorderType, Borders, Paragraph, Widget}, widgets::{Block, BorderType, Borders, Paragraph, Widget},
}; };
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct CellWidget { pub struct CellWidget {
row: usize, row: usize,
col: usize, col: usize,
selected: bool, selected: bool,
zoom_level: ZoomLevel, zoom_level: ZoomLevel,
structure: Structures, structure: Structures,
marked: bool, marked: bool,
unit: Option<Units>, unit: Option<Units>,
} }
impl CellWidget { impl CellWidget {
pub fn new( pub fn new(
row: usize, row: usize,
col: usize, col: usize,
zoom_level: ZoomLevel, zoom_level: ZoomLevel,
selected: bool, selected: bool,
structure: Structures, structure: Structures,
unit: Option<Units>, unit: Option<Units>,
) -> Self { ) -> Self {
Self { Self {
row, row,
col, col,
selected, selected,
zoom_level, zoom_level,
marked: false, marked: false,
structure, structure,
unit, unit,
} }
}
pub fn set_selected(&mut self, selected: bool) -> &mut Self {
self.selected = selected;
self
}
pub fn set_zoom_level(&mut self, zoom_level: ZoomLevel) -> &mut Self {
self.zoom_level = zoom_level;
self
}
pub fn get_structure(&self) -> Structures {
self.structure
}
pub fn get_option_unit(&self) -> Option<Units> {
self.unit.clone()
}
pub fn get_ref_option_unit(&self) -> &Option<Units> {
&self.unit
}
pub fn get_mut_option_unit(&mut self) -> &mut Option<Units> {
&mut self.unit
}
pub fn set_structure(&mut self, structure: Structures) -> &mut Self {
self.structure = structure;
self
}
pub fn get_marked(&self) -> bool {
self.marked
}
pub fn set_marked(&mut self, marked: bool) -> &mut Self {
self.marked = marked;
self
}
fn col_to_letters(&self) -> String {
let mut col: usize = self.col + 1;
let mut letters: Vec<char> = Vec::new();
while col > 0 {
letters.push((b'A' + ((col - 1) % 26) as u8) as char);
col = (col - 1) / 26;
} }
letters.iter().rev().collect() pub fn set_selected(&mut self, selected: bool) -> &mut Self {
} self.selected = selected;
self
}
fn display_coords(&self) -> Span<'_> { pub fn set_zoom_level(&mut self, zoom_level: ZoomLevel) -> &mut Self {
if self.selected || self.structure != Structures::Stone(Stone::new()) || self.marked { self.zoom_level = zoom_level;
format!("{}{}", self.col_to_letters(), self.row).green() self
} else {
"".to_span()
} }
}
fn fg_color(&self) -> Color { pub fn get_structure(&self) -> Structures {
if self.marked && self.selected { self.structure
Color::Magenta
} else if self.marked && !self.selected {
Color::LightMagenta
} else if !self.marked && self.selected {
Color::LightYellow
} else {
self.structure.get_color()
} }
}
fn get_text_area(&self) -> Vec<Line<'_>> { pub fn get_option_unit(&self) -> Option<Units> {
let mut lines: Vec<Line<'_>> = self.zoom_level.get_cell_text_area( self.unit.clone()
self.structure.get_tag(),
self.structure.get_level(),
self
.unit
.get_tag()
.to_string()
.fg(self.unit.get_owner().get_color()),
);
if let Structures::Stone(stone) = self.structure
&& stone.get_durability() < stone.get_max_durability()
{
let percent: u32 =
u32::from(stone.get_durability()) * 100 / u32::from(stone.get_max_durability());
let middle = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).cyan();
} }
if let Structures::MineConstruction(site) = self.structure {
let middle: usize = lines.len() / 2;
lines[middle] = Line::from(format!("{}%", site.progress_percent())).cyan();
}
if let Some(unit) = &self.unit
&& let Some(Tasks::Moving(task)) = unit.get_ref_tasks().front()
&& let Some(percent) = task.get_movement_progress()
{
let middle = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).fg(unit.get_owner().get_color());
}
lines
}
fn get_block(&self) -> Block<'_> { pub fn get_ref_option_unit(&self) -> &Option<Units> {
Block::default() &self.unit
.borders(Borders::ALL) }
.style(Style::default().fg(self.fg_color()))
.title(self.display_coords()) pub fn get_mut_option_unit(&mut self) -> &mut Option<Units> {
.border_type(BorderType::Rounded) &mut self.unit
} }
pub fn set_structure(&mut self, structure: Structures) -> &mut Self {
self.structure = structure;
self
}
pub fn get_marked(&self) -> bool {
self.marked
}
pub fn set_marked(&mut self, marked: bool) -> &mut Self {
self.marked = marked;
self
}
fn col_to_letters(&self) -> String {
let mut col: usize = self.col + 1;
let mut letters: Vec<char> = Vec::new();
while col > 0 {
letters.push((b'A' + ((col - 1) % 26) as u8) as char);
col = (col - 1) / 26;
}
letters.iter().rev().collect()
}
fn display_coords(&self) -> Span<'_> {
if self.selected || self.structure != Structures::Stone(Stone::new()) || self.marked {
format!("{}{}", self.col_to_letters(), self.row).green()
} else {
"".to_span()
}
}
fn fg_color(&self) -> Color {
if self.marked && self.selected {
Color::Magenta
} else if self.marked && !self.selected {
Color::LightMagenta
} else if !self.marked && self.selected {
Color::LightYellow
} else {
self.structure.get_color()
}
}
fn get_text_area(&self) -> Vec<Line<'_>> {
let mut lines: Vec<Line<'_>> = self.zoom_level.get_cell_text_area(
self.structure.get_tag(),
self.structure.get_level(),
self.unit
.get_tag()
.to_string()
.fg(self.unit.get_owner().get_color()),
);
if let Structures::Stone(stone) = self.structure
&& stone.get_durability() < stone.get_max_durability()
{
let percent: u32 =
u32::from(stone.get_durability()) * 100 / u32::from(stone.get_max_durability());
let middle = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).cyan();
}
if let Structures::MineConstruction(site) = self.structure {
let middle: usize = lines.len() / 2;
lines[middle] = Line::from(format!("{}%", site.progress_percent())).cyan();
}
if let Some(unit) = &self.unit
&& let Some(Tasks::Moving(task)) = unit.get_ref_tasks().front()
&& let Some(percent) = task.get_movement_progress()
{
let middle = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).fg(unit.get_owner().get_color());
}
lines
}
fn get_block(&self) -> Block<'_> {
Block::default()
.borders(Borders::ALL)
.style(Style::default().fg(self.fg_color()))
.title(self.display_coords())
.border_type(BorderType::Rounded)
}
} }
impl Widget for CellWidget { impl Widget for CellWidget {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
Paragraph::new(self.get_text_area()) Paragraph::new(self.get_text_area())
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block(self.get_block()) .block(self.get_block())
.render(area, buf); .render(area, buf);
} }
} }
+133 -128
View File
@@ -1,14 +1,14 @@
use crate::app::{ use crate::app::{
helpers::block_single_title_helper, helpers::block_single_title_helper,
keybindings::{Action, Group, KeyBinding, binding_for}, keybindings::{Action, Group, KeyBinding, binding_for},
}; };
use clap::ValueEnum; use clap::ValueEnum;
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Alignment, Constraint, Layout, Rect}, layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Stylize}, style::{Color, Stylize},
text::Line, text::Line,
widgets::{Block, Borders, Padding, Paragraph, Widget}, widgets::{Block, Borders, Padding, Paragraph, Widget},
}; };
use std::collections::HashSet; use std::collections::HashSet;
@@ -18,136 +18,141 @@ use std::collections::HashSet;
/// logical group of keybindings (e.g. navigation, editing, etc.). The paragraphs /// logical group of keybindings (e.g. navigation, editing, etc.). The paragraphs
/// are rendered side‑by‑side inside a bordered container. /// are rendered side‑by‑side inside a bordered container.
pub struct KeybindingsWidget { pub struct KeybindingsWidget {
/// Paragraphs, one per used `Group`, containing the formatted keybinding lines. /// Paragraphs, one per used `Group`, containing the formatted keybinding lines.
grouped: Vec<Paragraph<'static>>, grouped: Vec<Paragraph<'static>>,
widths: Vec<u16>, widths: Vec<u16>,
} }
impl KeybindingsWidget { impl KeybindingsWidget {
/// Create a new widget from a list of actions. /// Create a new widget from a list of actions.
/// ///
/// For each action, the corresponding static [`KeyBinding`] is looked up /// For each action, the corresponding static [`KeyBinding`] is looked up
/// using [`binding_for`]. Only groups that have at least one binding are /// using [`binding_for`]. Only groups that have at least one binding are
/// displayed. The resulting widget contains a paragraph for each group, /// displayed. The resulting widget contains a paragraph for each group,
/// formatted with the key symbol (red & bold) followed by its description. /// formatted with the key symbol (red & bold) followed by its description.
/// ///
/// # Parameters /// # Parameters
/// ///
/// * `actions` – A slice of actions for which keybindings should be shown. /// * `actions` – A slice of actions for which keybindings should be shown.
/// * `area_height` – The height of the area in which the widget will be rendered. /// * `area_height` – The height of the area in which the widget will be rendered.
/// This value is used to calculate vertical padding so that the keybinding rows /// This value is used to calculate vertical padding so that the keybinding rows
/// are vertically centered when the widget's allocated height is larger than the /// are vertically centered when the widget's allocated height is larger than the
/// number of lines needed to display the bindings. /// number of lines needed to display the bindings.
/// ///
/// # Returns /// # Returns
/// ///
/// A fully‑initialised `KeybindingsWidget` ready for rendering. /// A fully‑initialised `KeybindingsWidget` ready for rendering.
pub fn new(actions: &[Action], area_height: u16) -> Self { pub fn new(actions: &[Action], area_height: u16) -> Self {
Self::with_descriptions(actions, area_height, &[]) Self::with_descriptions(actions, area_height, &[])
}
pub fn with_descriptions(
actions: &[Action],
area_height: u16,
descriptions: &[(Action, &'static str)],
) -> Self {
let keybindings: Vec<Option<&'static KeyBinding>> =
actions.iter().map(|a| binding_for(*a)).collect();
let used_groups: HashSet<Group> = keybindings
.iter()
.filter_map(|b| b.map(|binding| binding.group))
.collect();
let mut grouped_keybindings: Vec<Paragraph<'static>> = Vec::new();
let mut widths: Vec<u16> = Vec::new();
for group in Group::value_variants().iter() {
if !used_groups.contains(group) {
continue;
}
let grouped_lines: Vec<Line<'_>> = keybindings
.iter()
.filter(|b| b.as_ref().map_or(false, |kb| kb.group == *group))
.filter_map(|b| {
b.as_ref().map(|kb| {
let description: &'static str = descriptions
.iter()
.find(|(action, _): &&(Action, &'static str)| -> bool { *action == kb.action })
.map_or(
kb.description,
|(_, description): &(Action, &'static str)| -> &'static str { description },
);
Line::from_iter([kb.symbol.red().bold(), " - ".into(), description.into()])
})
})
.collect();
let width: usize = grouped_lines.iter().map(Line::width).max().unwrap_or(0);
widths.push(u16::try_from(width).unwrap_or(u16::MAX).saturating_add(3));
let height_diff: u16 =
area_height.saturating_sub(grouped_lines.len().saturating_add(2) as u16);
let padding_top: u16 = if height_diff <= 1 {
height_diff
} else {
height_diff / 2
};
let mut block: Block<'_> = Block::default().padding(Padding::new(1, 1, padding_top, 0));
if !grouped_keybindings.is_empty() {
block = block.borders(Borders::LEFT);
}
grouped_keybindings.push(
Paragraph::new(grouped_lines)
.alignment(Alignment::Center)
.block(block),
);
} }
Self { pub fn with_descriptions(
grouped: grouped_keybindings, actions: &[Action],
widths, area_height: u16,
descriptions: &[(Action, &'static str)],
) -> Self {
let keybindings: Vec<Option<&'static KeyBinding>> =
actions.iter().map(|a| binding_for(*a)).collect();
let used_groups: HashSet<Group> = keybindings
.iter()
.filter_map(|b| b.map(|binding| binding.group))
.collect();
let mut grouped_keybindings: Vec<Paragraph<'static>> = Vec::new();
let mut widths: Vec<u16> = Vec::new();
for group in Group::value_variants().iter() {
if !used_groups.contains(group) {
continue;
}
let grouped_lines: Vec<Line<'_>> = keybindings
.iter()
.filter(|b| b.as_ref().map_or(false, |kb| kb.group == *group))
.filter_map(|b| {
b.as_ref().map(|kb| {
let description: &'static str = descriptions
.iter()
.find(|(action, _): &&(Action, &'static str)| -> bool {
*action == kb.action
})
.map_or(
kb.description,
|(_, description): &(Action, &'static str)| -> &'static str {
description
},
);
Line::from_iter([kb.symbol.red().bold(), " - ".into(), description.into()])
})
})
.collect();
let width: usize = grouped_lines.iter().map(Line::width).max().unwrap_or(0);
widths.push(u16::try_from(width).unwrap_or(u16::MAX).saturating_add(3));
let height_diff: u16 =
area_height.saturating_sub(grouped_lines.len().saturating_add(2) as u16);
let padding_top: u16 = if height_diff <= 1 {
height_diff
} else {
height_diff / 2
};
let mut block: Block<'_> = Block::default().padding(Padding::new(1, 1, padding_top, 0));
if !grouped_keybindings.is_empty() {
block = block.borders(Borders::LEFT);
}
grouped_keybindings.push(
Paragraph::new(grouped_lines)
.alignment(Alignment::Center)
.block(block),
);
}
Self {
grouped: grouped_keybindings,
widths,
}
} }
}
} }
impl Widget for KeybindingsWidget { impl Widget for KeybindingsWidget {
/// Render the widget inside the given rectangular area. /// Render the widget inside the given rectangular area.
/// ///
/// The widget first draws a surrounding border with the title *Keybindings*. /// The widget first draws a surrounding border with the title *Keybindings*.
/// It then divides the inner area proportionally to each group's text width—one per /// It then divides the inner area proportionally to each group's text width—one per
/// used group—and renders each group's paragraph into its slice. /// used group—and renders each group's paragraph into its slice.
/// ///
/// If no groups are present, the function returns early without drawing /// If no groups are present, the function returns early without drawing
/// anything. /// anything.
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let count: u16 = self.grouped.len() as u16; let count: u16 = self.grouped.len() as u16;
if count == 0 { if count == 0 {
return; return;
}
let block: Block<'_> =
Block::default()
.borders(Borders::ALL)
.title(block_single_title_helper(
"Keybindings".to_string(),
Color::Magenta,
));
let inner: Rect = block.inner(area);
block.render(area, buf);
let constraints: Vec<Constraint> = self.widths.into_iter().map(Constraint::Fill).collect();
let chunks: Vec<Rect> = Layout::horizontal(constraints).split(inner).to_vec();
for (paragraph, chunk) in self.grouped.into_iter().zip(chunks.into_iter()) {
paragraph.render(chunk, buf);
}
} }
let block: Block<'_> = Block::default()
.borders(Borders::ALL)
.title(block_single_title_helper(
"Keybindings".to_string(),
Color::Magenta,
));
let inner: Rect = block.inner(area);
block.render(area, buf);
let constraints: Vec<Constraint> = self.widths.into_iter().map(Constraint::Fill).collect();
let chunks: Vec<Rect> = Layout::horizontal(constraints).split(inner).to_vec();
for (paragraph, chunk) in self.grouped.into_iter().zip(chunks.into_iter()) {
paragraph.render(chunk, buf);
}
}
} }
+102 -102
View File
@@ -1,127 +1,127 @@
use crate::app::{ use crate::app::{
helpers::block_single_title_helper, helpers::block_single_title_helper,
states::skirmish_states::{ states::skirmish_states::{
structures::{Structure, Structures}, structures::{Structure, Structures},
units::{Unit, Units}, units::{Unit, Units},
}, },
}; };
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Alignment, Constraint, Layout, Margin, Rect}, layout::{Alignment, Constraint, Layout, Margin, Rect},
style::Color, style::Color,
text::Line, text::Line,
widgets::{Block, BorderType, Borders, Padding, Paragraph, Widget}, widgets::{Block, BorderType, Borders, Padding, Paragraph, Widget},
}; };
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct SidePanelWidget<'a> { pub struct SidePanelWidget<'a> {
coords: (usize, usize),
structure: &'a Structures,
unit: &'a Option<Units>,
is_unit_marked: bool,
}
impl<'a> SidePanelWidget<'a> {
pub fn new(
coords: (usize, usize), coords: (usize, usize),
structure: &'a Structures, structure: &'a Structures,
unit: &'a Option<Units>, unit: &'a Option<Units>,
is_unit_marked: bool, is_unit_marked: bool,
) -> Self { }
Self {
coords,
structure,
unit,
is_unit_marked,
}
}
fn col_to_letters(&self) -> String { impl<'a> SidePanelWidget<'a> {
let mut col: usize = self.coords.1 + 1; pub fn new(
let mut letters: Vec<char> = Vec::new(); coords: (usize, usize),
structure: &'a Structures,
while col > 0 { unit: &'a Option<Units>,
letters.push((b'A' + ((col - 1) % 26) as u8) as char); is_unit_marked: bool,
col = (col - 1) / 26; ) -> Self {
Self {
coords,
structure,
unit,
is_unit_marked,
}
} }
letters.iter().rev().collect() fn col_to_letters(&self) -> String {
} let mut col: usize = self.coords.1 + 1;
let mut letters: Vec<char> = Vec::new();
fn get_outer_block(&self) -> Block<'_> { while col > 0 {
Block::default() letters.push((b'A' + ((col - 1) % 26) as u8) as char);
.borders(Borders::ALL) col = (col - 1) / 26;
.border_type(BorderType::Double) }
.title(block_single_title_helper(
format!("{}{}", self.col_to_letters(), self.coords.0),
Color::Yellow,
))
.title_alignment(Alignment::Center)
}
fn get_inner_block<'b>(&self, title: String, color: Color) -> Block<'b> { letters.iter().rev().collect()
Block::default() }
.borders(Borders::TOP)
.title(block_single_title_helper(title, color))
.title_alignment(Alignment::Center)
.padding(Padding::symmetric(1, 1))
}
fn get_area_constraints(&self) -> [Constraint; 2] { fn get_outer_block(&self) -> Block<'_> {
if self.unit.is_unit() { Block::default()
[Constraint::Percentage(50), Constraint::Percentage(50)] .borders(Borders::ALL)
} else { .border_type(BorderType::Double)
[Constraint::Percentage(100), Constraint::Percentage(0)] .title(block_single_title_helper(
format!("{}{}", self.col_to_letters(), self.coords.0),
Color::Yellow,
))
.title_alignment(Alignment::Center)
}
fn get_inner_block<'b>(&self, title: String, color: Color) -> Block<'b> {
Block::default()
.borders(Borders::TOP)
.title(block_single_title_helper(title, color))
.title_alignment(Alignment::Center)
.padding(Padding::symmetric(1, 1))
}
fn get_area_constraints(&self) -> [Constraint; 2] {
if self.unit.is_unit() {
[Constraint::Percentage(50), Constraint::Percentage(50)]
} else {
[Constraint::Percentage(100), Constraint::Percentage(0)]
}
} }
}
} }
impl Widget for SidePanelWidget<'_> { impl Widget for SidePanelWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let block: Block<'_> = self.get_outer_block(); let block: Block<'_> = self.get_outer_block();
let inner_area: Rect = block.inner(area).inner(Margin { let inner_area: Rect = block.inner(area).inner(Margin {
horizontal: 2, horizontal: 2,
vertical: 1, vertical: 1,
}); });
block.render(area, buf); block.render(area, buf);
let [structure_area, unit_area] = let [structure_area, unit_area] =
Layout::vertical(self.get_area_constraints()).areas(inner_area); Layout::vertical(self.get_area_constraints()).areas(inner_area);
let mut structure_text: Vec<Line<'_>> = self.structure.text(); let mut structure_text: Vec<Line<'_>> = self.structure.text();
if let Structures::Base(base) = self.structure if let Structures::Base(base) = self.structure
&& base.queued_miners() > 0 && base.queued_miners() > 0
&& self.unit.is_some() && self.unit.is_some()
{ {
structure_text.push(Line::from(base.recruitment_status(true))); structure_text.push(Line::from(base.recruitment_status(true)));
}
if let Structures::Mine(mine) = self.structure {
let staffed: bool = self
.unit
.as_ref()
.is_some_and(|unit: &Units| -> bool { mine.accepts_unit(unit) });
structure_text.push(Line::from(format!(
"Metal: {}/s{}",
mine.production_per_cycle(staffed),
if staffed { " (Miner)" } else { "" }
)));
}
Paragraph::new(structure_text)
.alignment(Alignment::Left)
.block(self.get_inner_block(self.structure.get_name().to_string(), Color::Cyan))
.render(structure_area, buf);
if self.unit.is_unit() {
let mut unit_name: String = self.unit.get_name().to_string();
if self.is_unit_marked {
unit_name = format!("> {} <", unit_name);
}
Paragraph::new(self.unit.text())
.alignment(Alignment::Left)
.block(self.get_inner_block(unit_name, Color::Green))
.render(unit_area, buf);
}
} }
if let Structures::Mine(mine) = self.structure {
let staffed: bool = self
.unit
.as_ref()
.is_some_and(|unit: &Units| -> bool { mine.accepts_unit(unit) });
structure_text.push(Line::from(format!(
"Metal: {}/s{}",
mine.production_per_cycle(staffed),
if staffed { " (Miner)" } else { "" }
)));
}
Paragraph::new(structure_text)
.alignment(Alignment::Left)
.block(self.get_inner_block(self.structure.get_name().to_string(), Color::Cyan))
.render(structure_area, buf);
if self.unit.is_unit() {
let mut unit_name: String = self.unit.get_name().to_string();
if self.is_unit_marked {
unit_name = format!("> {} <", unit_name);
}
Paragraph::new(self.unit.text())
.alignment(Alignment::Left)
.block(self.get_inner_block(unit_name, Color::Green))
.render(unit_area, buf);
}
}
} }
+93 -93
View File
@@ -1,13 +1,13 @@
use crate::{ use crate::{
app::{ app::{
states::{ states::{
PerkDecks, PerkDecks,
skirmish_states::{GameMode, ZoomLevel}, skirmish_states::{GameMode, ZoomLevel},
},
threads::Soundtrack,
view::View,
}, },
threads::Soundtrack, logs::init_logger,
view::View,
},
logs::init_logger,
}; };
use clap::{Error, Parser, error::ErrorKind, value_parser}; use clap::{Error, Parser, error::ErrorKind, value_parser};
use std::num::ParseFloatError; use std::num::ParseFloatError;
@@ -21,8 +21,8 @@ use std::num::ParseFloatError;
#[derive(Parser, Debug, Clone)] #[derive(Parser, Debug, Clone)]
#[command(version, about = "War in Tunnels", long_about = "War in Tunnels")] #[command(version, about = "War in Tunnels", long_about = "War in Tunnels")]
pub struct Cli { pub struct Cli {
/// The initial view/window to display. /// The initial view/window to display.
#[arg( #[arg(
long, long,
short, short,
help = "Default window", help = "Default window",
@@ -30,20 +30,20 @@ pub struct Cli {
default_value_t = View::MainMenu, default_value_t = View::MainMenu,
value_enum value_enum
)] )]
pub view: View, pub view: View,
/// Player name shown in the UI. /// Player name shown in the UI.
#[arg( #[arg(
long, long,
short, short,
help = "Username", help = "Username",
value_name = "String", value_name = "String",
default_value = "Player" default_value = "Player"
)] )]
pub username: String, pub username: String,
/// Game mode selection. /// Game mode selection.
#[arg( #[arg(
long, long,
short, short,
help = "Game mode", help = "Game mode",
@@ -51,10 +51,10 @@ pub struct Cli {
default_value_t = GameMode::LastManStanding, default_value_t = GameMode::LastManStanding,
value_enum value_enum
)] )]
pub game_mode: GameMode, pub game_mode: GameMode,
/// Width of the generated map (36–100 tiles). /// Width of the generated map (36–100 tiles).
#[arg( #[arg(
long, long,
short = 'W', short = 'W',
help = "Map width", help = "Map width",
@@ -62,10 +62,10 @@ pub struct Cli {
default_value = "42", default_value = "42",
value_parser = value_parser!(u8).range(36..=108) value_parser = value_parser!(u8).range(36..=108)
)] )]
pub map_width: u8, pub map_width: u8,
/// Height of the generated map (11–50 tiles). /// Height of the generated map (11–50 tiles).
#[arg( #[arg(
long, long,
short = 'H', short = 'H',
help = "Map height", help = "Map height",
@@ -73,10 +73,10 @@ pub struct Cli {
default_value = "11", default_value = "11",
value_parser = value_parser!(u8).range(11..=39) value_parser = value_parser!(u8).range(11..=39)
)] )]
pub map_height: u8, pub map_height: u8,
/// Zoom level used for the UI. /// Zoom level used for the UI.
#[arg( #[arg(
long, long,
short, short,
help = "Zoom level", help = "Zoom level",
@@ -84,10 +84,10 @@ pub struct Cli {
default_value_t = ZoomLevel::Default, default_value_t = ZoomLevel::Default,
value_enum value_enum
)] )]
pub zoom_level: ZoomLevel, pub zoom_level: ZoomLevel,
/// Which perk deck to use. /// Which perk deck to use.
#[arg( #[arg(
long, long,
short, short,
help = "Perk Deck", help = "Perk Deck",
@@ -95,10 +95,10 @@ pub struct Cli {
default_value_t = PerkDecks::Silesian, default_value_t = PerkDecks::Silesian,
value_enum value_enum
)] )]
pub perk_deck: PerkDecks, pub perk_deck: PerkDecks,
/// Starting amount of wood (1–150). /// Starting amount of wood (1–150).
#[arg( #[arg(
long, long,
short = 'w', short = 'w',
help = "Starting wood", help = "Starting wood",
@@ -106,10 +106,10 @@ pub struct Cli {
default_value = "50", default_value = "50",
value_parser = value_parser!(u16).range(1..=150) value_parser = value_parser!(u16).range(1..=150)
)] )]
pub starting_wood: u16, pub starting_wood: u16,
/// Starting amount of metal (1–150). /// Starting amount of metal (1–150).
#[arg( #[arg(
long, long,
short = 'i', short = 'i',
help = "Starting metal", help = "Starting metal",
@@ -118,10 +118,10 @@ pub struct Cli {
default_value = "25", default_value = "25",
value_parser = value_parser!(u16).range(1..=150) value_parser = value_parser!(u16).range(1..=150)
)] )]
pub starting_metal: u16, pub starting_metal: u16,
/// Maximum amount of units (49–149). /// Maximum amount of units (49–149).
#[arg( #[arg(
long, long,
short = 'L', short = 'L',
help = "Supply limit", help = "Supply limit",
@@ -129,10 +129,10 @@ pub struct Cli {
default_value = "99", default_value = "99",
value_parser = value_parser!(u8).range(49..=149) value_parser = value_parser!(u8).range(49..=149)
)] )]
pub supply_limit: u8, pub supply_limit: u8,
/// Modifier applied to experience points (0.5–2.0). /// Modifier applied to experience points (0.5–2.0).
#[arg( #[arg(
long, long,
short, short,
help = "XP modifier", help = "XP modifier",
@@ -140,10 +140,10 @@ pub struct Cli {
default_value = "1.0", default_value = "1.0",
value_parser = parse_xp value_parser = parse_xp
)] )]
pub xp_modifier: f32, pub xp_modifier: f32,
/// Upper bound for skill points that can be earned (90–690). /// Upper bound for skill points that can be earned (90–690).
#[arg( #[arg(
long, long,
short = 'P', short = 'P',
help = "Skill points limit", help = "Skill points limit",
@@ -151,10 +151,10 @@ pub struct Cli {
default_value = "120", default_value = "120",
value_parser = value_parser!(u16).range(90..=690) value_parser = value_parser!(u16).range(90..=690)
)] )]
pub skill_points_limit: u16, pub skill_points_limit: u16,
/// Which soundtrack to play (default: default) /// Which soundtrack to play (default: default)
#[arg( #[arg(
long, long,
short = 'S', short = 'S',
help = "Soundtrack", help = "Soundtrack",
@@ -162,34 +162,34 @@ pub struct Cli {
default_value_t = Soundtrack::Default, default_value_t = Soundtrack::Default,
value_enum value_enum
)] )]
pub soundtrack: Soundtrack, pub soundtrack: Soundtrack,
/// Disable soundtrack (default: disabled). /// Disable soundtrack (default: disabled).
#[arg( #[arg(
long, long,
short = 'D', short = 'D',
help = "Disable soundtrack (default: disable)", help = "Disable soundtrack (default: disable)",
default_value_t = false default_value_t = false
)] )]
pub disable_audio: bool, pub disable_audio: bool,
/// Mute soundtrack (default: disabled). /// Mute soundtrack (default: disabled).
#[arg( #[arg(
long, long,
short = 'm', short = 'm',
help = "Mute soundtrack (default: disabled)", help = "Mute soundtrack (default: disabled)",
default_value_t = false default_value_t = false
)] )]
pub mute: bool, pub mute: bool,
/// Enable logging to a file (default: disabled). /// Enable logging to a file (default: disabled).
#[arg( #[arg(
long, long,
short = 'l', short = 'l',
help = "Enable logging to file (default: disabled)", help = "Enable logging to file (default: disabled)",
default_value_t = false default_value_t = false
)] )]
pub log: bool, pub log: bool,
} }
/// Parses the command line arguments and returns a fully populated `Cli` /// Parses the command line arguments and returns a fully populated `Cli`
@@ -197,13 +197,13 @@ pub struct Cli {
/// handles argument validation and displays helpful error messages if /// handles argument validation and displays helpful error messages if
/// the user supplies invalid input. /// the user supplies invalid input.
pub fn get_args() -> Cli { pub fn get_args() -> Cli {
let args: Cli = Cli::parse(); let args: Cli = Cli::parse();
if args.log { if args.log {
init_logger(); init_logger();
} }
args args
} }
/// Parses a string into a floating‑point XP modifier and validates that it /// Parses a string into a floating‑point XP modifier and validates that it
@@ -218,15 +218,15 @@ pub fn get_args() -> Cli {
/// * Returns an error if the string cannot be parsed as `f32`. /// * Returns an error if the string cannot be parsed as `f32`.
/// * Returns an error if the parsed value is outside the allowed range. /// * Returns an error if the parsed value is outside the allowed range.
fn parse_xp(s: &str) -> Result<f32, Error> { fn parse_xp(s: &str) -> Result<f32, Error> {
let v: f32 = s let v: f32 = s
.parse() .parse()
.map_err(|e: ParseFloatError| Error::raw(ErrorKind::InvalidValue, e.to_string()))?; .map_err(|e: ParseFloatError| Error::raw(ErrorKind::InvalidValue, e.to_string()))?;
if (0.5..=2.0).contains(&v) { if (0.5..=2.0).contains(&v) {
Ok(v) Ok(v)
} else { } else {
Err(Error::raw( Err(Error::raw(
ErrorKind::InvalidValue, ErrorKind::InvalidValue,
format!("Value {} is out of range 0.5..=2.0", v), format!("Value {} is out of range 0.5..=2.0", v),
)) ))
} }
} }
+6 -6
View File
@@ -28,10 +28,10 @@ use std::fs::File;
/// } /// }
/// ``` /// ```
pub fn init_logger() { pub fn init_logger() {
WriteLogger::init( WriteLogger::init(
LevelFilter::Info, LevelFilter::Info,
Config::default(), Config::default(),
File::create("war_in_tunnels.log").expect("Failed to create log file"), File::create("war_in_tunnels.log").expect("Failed to create log file"),
) )
.expect("Failed to initialize logger"); .expect("Failed to initialize logger");
} }
+23 -23
View File
@@ -1,15 +1,15 @@
use ratatui::{Terminal, crossterm::event::KeyEvent, prelude::CrosstermBackend}; use ratatui::{Terminal, crossterm::event::KeyEvent, prelude::CrosstermBackend};
use std::{ use std::{
io::{Result, Stdout}, io::{Result, Stdout},
sync::mpsc::channel, sync::mpsc::channel,
thread, thread,
}; };
use war_in_tunnels::{ use war_in_tunnels::{
app::{ app::{
App, App,
threads::{AudioCmd, handle_audio, handle_ct_events, handle_tick_event}, threads::{AudioCmd, handle_audio, handle_ct_events, handle_tick_event},
}, },
cli::{Cli, get_args}, cli::{Cli, get_args},
}; };
/// Starts the terminal UI application. /// Starts the terminal UI application.
@@ -18,25 +18,25 @@ use war_in_tunnels::{
/// It returns any `std::io::Error` encountered while initializing or restoring the /// It returns any `std::io::Error` encountered while initializing or restoring the
/// terminal, or while running the `App`. /// terminal, or while running the `App`.
fn main() -> Result<()> { fn main() -> Result<()> {
let args: Cli = get_args(); let args: Cli = get_args();
let (input_tx, input_rx) = channel::<KeyEvent>(); let (input_tx, input_rx) = channel::<KeyEvent>();
let (resize_tx, resize_rx) = channel::<(u16, u16)>(); let (resize_tx, resize_rx) = channel::<(u16, u16)>();
let (tick_tx, tick_rx) = channel::<()>(); let (tick_tx, tick_rx) = channel::<()>();
let (audio_tx, audio_rx) = channel::<AudioCmd>(); let (audio_tx, audio_rx) = channel::<AudioCmd>();
thread::spawn(move || handle_ct_events(input_tx, resize_tx)); thread::spawn(move || handle_ct_events(input_tx, resize_tx));
thread::spawn(move || handle_tick_event(tick_tx)); thread::spawn(move || handle_tick_event(tick_tx));
if !args.disable_audio { if !args.disable_audio {
thread::spawn(move || handle_audio(audio_rx, args.mute, args.soundtrack)); thread::spawn(move || handle_audio(audio_rx, args.mute, args.soundtrack));
} }
let mut terminal: Terminal<CrosstermBackend<Stdout>> = ratatui::init(); let mut terminal: Terminal<CrosstermBackend<Stdout>> = ratatui::init();
let mut app: App = App::new(args, audio_tx); let mut app: App = App::new(args, audio_tx);
let app_result: Result<()> = app.run(&mut terminal, input_rx, resize_rx, tick_rx); let app_result: Result<()> = app.run(&mut terminal, input_rx, resize_rx, tick_rx);
ratatui::restore(); ratatui::restore();
app_result app_result
} }
+118 -119
View File
@@ -1,162 +1,161 @@
use clap::Parser; use clap::Parser;
use ratatui::{ use ratatui::{
Terminal, Terminal,
backend::TestBackend, backend::TestBackend,
crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
layout::Rect, layout::Rect,
}; };
use std::sync::mpsc::{Receiver, channel}; use std::sync::mpsc::{Receiver, channel};
use war_in_tunnels::{ use war_in_tunnels::{
app::{App, GameStates, View, handle_keybindings, threads::AudioCmd}, app::{App, GameStates, View, handle_keybindings, threads::AudioCmd},
cli::Cli, cli::Cli,
}; };
fn app() -> (App, Receiver<AudioCmd>) { fn app() -> (App, Receiver<AudioCmd>) {
let args = Cli::try_parse_from(["game", "--disable-audio"]).unwrap(); let args = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, rx) = channel(); let (tx, rx) = channel();
let mut app = App::new(args, tx); let mut app = App::new(args, tx);
app.window_area = Rect::new(0, 0, 120, 40); app.window_area = Rect::new(0, 0, 120, 40);
app.states = Some(GameStates::new(&app.args, &app.window_area)); app.states = Some(GameStates::new(&app.args, &app.window_area));
(app, rx) (app, rx)
} }
fn press(app: &mut App, code: KeyCode) { fn press(app: &mut App, code: KeyCode) {
handle_keybindings(app, KeyEvent::new(code, KeyModifiers::NONE)); handle_keybindings(app, KeyEvent::new(code, KeyModifiers::NONE));
} }
#[test] #[test]
fn menu_selection_stays_in_bounds_and_opens_each_view() { fn menu_selection_stays_in_bounds_and_opens_each_view() {
let (mut app, _) = app(); let (mut app, _) = app();
for _ in 0..10 { for _ in 0..10 {
press(&mut app, KeyCode::Up); press(&mut app, KeyCode::Up);
} }
assert_eq!(app.states_ref().unwrap().main_menu.selected_view, 1); assert_eq!(app.states_ref().unwrap().main_menu.selected_view, 1);
for view in [View::Skirmish, View::PerkDecks, View::SkillsConfig] { for view in [View::Skirmish, View::PerkDecks, View::SkillsConfig] {
press(&mut app, KeyCode::Char(' ')); press(&mut app, KeyCode::Char(' '));
assert_eq!(app.view, view); assert_eq!(app.view, view);
press(&mut app, KeyCode::Esc); press(&mut app, KeyCode::Esc);
assert_eq!(app.view, View::MainMenu); assert_eq!(app.view, View::MainMenu);
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Down);
} }
for _ in 0..10 { for _ in 0..10 {
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Down);
} }
assert_eq!(app.states_ref().unwrap().main_menu.selected_view, 3); assert_eq!(app.states_ref().unwrap().main_menu.selected_view, 3);
} }
#[test] #[test]
fn quit_keys_work_in_every_view() { fn quit_keys_work_in_every_view() {
for view in [ for view in [
View::MainMenu, View::MainMenu,
View::Skirmish, View::Skirmish,
View::PerkDecks, View::PerkDecks,
View::SkillsConfig, View::SkillsConfig,
] {
for key in [
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
] { ] {
let (mut app, _) = app(); for key in [
app.view = view; KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
handle_keybindings(&mut app, key); KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
assert!(app.exit); ] {
let (mut app, _) = app();
app.view = view;
handle_keybindings(&mut app, key);
assert!(app.exit);
}
} }
}
} }
#[test] #[test]
fn key_release_does_not_trigger_quit() { fn key_release_does_not_trigger_quit() {
let (mut app, _) = app(); let (mut app, _) = app();
handle_keybindings( handle_keybindings(
&mut app, &mut app,
KeyEvent::new_with_kind( KeyEvent::new_with_kind(
KeyCode::Char('q'), KeyCode::Char('q'),
KeyModifiers::NONE, KeyModifiers::NONE,
KeyEventKind::Release, KeyEventKind::Release,
), ),
); );
assert!(!app.exit); assert!(!app.exit);
} }
#[test] #[test]
fn music_keys_send_commands_without_opening_audio_device() { fn music_keys_send_commands_without_opening_audio_device() {
let (mut app, rx) = app(); let (mut app, rx) = app();
press(&mut app, KeyCode::Char('m')); press(&mut app, KeyCode::Char('m'));
press(&mut app, KeyCode::Char('b')); press(&mut app, KeyCode::Char('b'));
press(&mut app, KeyCode::Char('n')); press(&mut app, KeyCode::Char('n'));
assert!(matches!(rx.try_recv().unwrap(), AudioCmd::Mute)); assert!(matches!(rx.try_recv().unwrap(), AudioCmd::Mute));
assert!(matches!(rx.try_recv().unwrap(), AudioCmd::VolumeUp)); assert!(matches!(rx.try_recv().unwrap(), AudioCmd::VolumeUp));
assert!(matches!(rx.try_recv().unwrap(), AudioCmd::VolumeDown)); assert!(matches!(rx.try_recv().unwrap(), AudioCmd::VolumeDown));
drop(rx); drop(rx);
press(&mut app, KeyCode::Char('m')); press(&mut app, KeyCode::Char('m'));
assert!(!app.exit); assert!(!app.exit);
} }
#[test] #[test]
fn skirmish_keys_move_focus_toggle_panel_and_limit_zoom() { fn skirmish_keys_move_focus_toggle_panel_and_limit_zoom() {
let (mut app, _) = app(); let (mut app, _) = app();
app.view = View::Skirmish; app.view = View::Skirmish;
press(&mut app, KeyCode::Right); press(&mut app, KeyCode::Right);
assert_eq!( assert_eq!(
app app.states_ref()
.states_ref() .unwrap()
.unwrap() .skirmish
.skirmish .board
.board .get_focused_cell()
.get_focused_cell() .get_coords(),
.get_coords(), (5, 4)
(5, 4) );
); press(&mut app, KeyCode::Tab);
press(&mut app, KeyCode::Tab); assert!(app.states_ref().unwrap().skirmish.side_panel);
assert!(app.states_ref().unwrap().skirmish.side_panel); press(&mut app, KeyCode::Tab);
press(&mut app, KeyCode::Tab); assert!(!app.states_ref().unwrap().skirmish.side_panel);
assert!(!app.states_ref().unwrap().skirmish.side_panel); for _ in 0..4 {
for _ in 0..4 { press(&mut app, KeyCode::Char(','));
press(&mut app, KeyCode::Char(',')); }
} assert_eq!(
assert_eq!( app.states_ref().unwrap().skirmish.board.zoom_level,
app.states_ref().unwrap().skirmish.board.zoom_level, war_in_tunnels::app::states::skirmish_states::ZoomLevel::ZoomedIn
war_in_tunnels::app::states::skirmish_states::ZoomLevel::ZoomedIn );
); for _ in 0..4 {
for _ in 0..4 { press(&mut app, KeyCode::Char('.'));
press(&mut app, KeyCode::Char('.')); }
} assert_eq!(
assert_eq!( app.states_ref().unwrap().skirmish.board.zoom_level,
app.states_ref().unwrap().skirmish.board.zoom_level, war_in_tunnels::app::states::skirmish_states::ZoomLevel::ZoomedOut
war_in_tunnels::app::states::skirmish_states::ZoomLevel::ZoomedOut );
);
} }
#[test] #[test]
fn all_views_render_without_real_terminal() { fn all_views_render_without_real_terminal() {
let (mut app, _) = app(); let (mut app, _) = app();
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap(); let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
for view in [ for view in [
View::MainMenu, View::MainMenu,
View::Skirmish, View::Skirmish,
View::PerkDecks, View::PerkDecks,
View::SkillsConfig, View::SkillsConfig,
] { ] {
app.view = view; app.view = view;
let before = app.states.clone(); let before = app.states.clone();
terminal terminal
.draw(|frame| frame.render_widget(&mut app, frame.area())) .draw(|frame| frame.render_widget(&mut app, frame.area()))
.unwrap(); .unwrap();
let buffer = terminal.backend().buffer(); let buffer = terminal.backend().buffer();
let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect(); let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
assert!(!text.trim().is_empty(), "{view:?}"); assert!(!text.trim().is_empty(), "{view:?}");
if matches!(view, View::PerkDecks | View::SkillsConfig) { if matches!(view, View::PerkDecks | View::SkillsConfig) {
assert!(text.contains("Work in progress")); assert!(text.contains("Work in progress"));
}
assert_eq!(app.states, before);
} }
assert_eq!(app.states, before);
}
} }
#[test] #[test]
fn common_keys_work_before_states_are_initialized() { fn common_keys_work_before_states_are_initialized() {
let (mut app, _) = app(); let (mut app, _) = app();
app.states = None; app.states = None;
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Down);
press(&mut app, KeyCode::Char('q')); press(&mut app, KeyCode::Char('q'));
assert!(app.exit); assert!(app.exit);
} }
+51 -51
View File
@@ -1,65 +1,65 @@
use rodio::Decoder; use rodio::Decoder;
use std::{ use std::{
fs::{self, read}, fs::{self, read},
io::{BufReader, Cursor}, io::{BufReader, Cursor},
path::PathBuf, path::PathBuf,
process::{Command, Output}, process::{Command, Output},
}; };
use war_in_tunnels::app::threads::{SoundrackParts, Soundtrack, load_audio}; use war_in_tunnels::app::threads::{SoundrackParts, Soundtrack, load_audio};
#[test] #[test]
fn audio_repeats_after_end_of_file() { fn audio_repeats_after_end_of_file() {
let data = read("soundtracks/default/calm.ogg").unwrap(); let data = read("soundtracks/default/calm.ogg").unwrap();
let sample_count = Decoder::new(BufReader::new(Cursor::new(data))) let sample_count = Decoder::new(BufReader::new(Cursor::new(data)))
.unwrap() .unwrap()
.count(); .count();
assert!(sample_count > 0); assert!(sample_count > 0);
let mut source = load_audio(&Soundtrack::Default, &SoundrackParts::Calm); let mut source = load_audio(&Soundtrack::Default, &SoundrackParts::Calm);
assert_eq!(source.by_ref().take(sample_count).count(), sample_count); assert_eq!(source.by_ref().take(sample_count).count(), sample_count);
assert_eq!(source.take(1024).count(), 1024); assert_eq!(source.take(1024).count(), 1024);
} }
#[test] #[test]
fn packaged_audio_loads_without_project_working_directory() -> () { fn packaged_audio_loads_without_project_working_directory() -> () {
if std::env::var_os("WAR_IN_TUNNELS_PACKAGED_AUDIO_TEST").is_some() { if std::env::var_os("WAR_IN_TUNNELS_PACKAGED_AUDIO_TEST").is_some() {
let mut source: rodio::source::Amplify< let mut source: rodio::source::Amplify<
rodio::decoder::LoopedDecoder<BufReader<Cursor<Vec<u8>>>>, rodio::decoder::LoopedDecoder<BufReader<Cursor<Vec<u8>>>>,
> = load_audio(&Soundtrack::Default, &SoundrackParts::Calm); > = load_audio(&Soundtrack::Default, &SoundrackParts::Calm);
assert_eq!(source.by_ref().take(1024).count(), 1024); assert_eq!(source.by_ref().take(1024).count(), 1024);
return; return;
} }
let directory: PathBuf = std::env::temp_dir().join(format!( let directory: PathBuf = std::env::temp_dir().join(format!(
"war-in-tunnels-packaged-audio-{}", "war-in-tunnels-packaged-audio-{}",
std::process::id() std::process::id()
)); ));
fs::create_dir_all(directory.join("soundtracks/default")).unwrap(); fs::create_dir_all(directory.join("soundtracks/default")).unwrap();
fs::create_dir_all(directory.join("working-directory")).unwrap(); fs::create_dir_all(directory.join("working-directory")).unwrap();
let executable: PathBuf = directory.join("audio-test"); let executable: PathBuf = directory.join("audio-test");
fs::copy(std::env::current_exe().unwrap(), &executable).unwrap(); fs::copy(std::env::current_exe().unwrap(), &executable).unwrap();
for filename in ["calm.ogg", "test.ogg"] { for filename in ["calm.ogg", "test.ogg"] {
fs::copy( fs::copy(
PathBuf::from("soundtracks/default").join(filename), PathBuf::from("soundtracks/default").join(filename),
directory.join("soundtracks/default").join(filename), directory.join("soundtracks/default").join(filename),
) )
.unwrap(); .unwrap();
} }
let output: Output = Command::new(executable) let output: Output = Command::new(executable)
.args([ .args([
"--exact", "--exact",
"packaged_audio_loads_without_project_working_directory", "packaged_audio_loads_without_project_working_directory",
"--nocapture", "--nocapture",
]) ])
.env("WAR_IN_TUNNELS_PACKAGED_AUDIO_TEST", "1") .env("WAR_IN_TUNNELS_PACKAGED_AUDIO_TEST", "1")
.current_dir(directory.join("working-directory")) .current_dir(directory.join("working-directory"))
.output() .output()
.unwrap(); .unwrap();
fs::remove_dir_all(directory).unwrap(); fs::remove_dir_all(directory).unwrap();
assert!( assert!(
output.status.success(), output.status.success(),
"{}{}", "{}{}",
String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr) String::from_utf8_lossy(&output.stderr)
); );
} }
+119 -119
View File
@@ -1,155 +1,155 @@
use ratatui::layout::Rect; use ratatui::layout::Rect;
use war_in_tunnels::app::states::skirmish_states::{ use war_in_tunnels::app::states::skirmish_states::{
BoardState, FocusedCell, MoveFocusedCell, Offset, Players, ZoomLevel, BoardState, FocusedCell, MoveFocusedCell, Offset, Players, ZoomLevel,
structures::{Structure, Structures}, structures::{Structure, Structures},
tasks::Task, tasks::Task,
units::Unit, units::Unit,
}; };
fn board() -> BoardState { fn board() -> BoardState {
BoardState::new(&Rect::new(0, 0, 120, 40), 42, 11, ZoomLevel::Default, false) BoardState::new(&Rect::new(0, 0, 120, 40), 42, 11, ZoomLevel::Default, false)
} }
#[test] #[test]
fn map_has_opposing_bases_ore_and_two_miners() { fn map_has_opposing_bases_ore_and_two_miners() {
let board = board(); let board = board();
assert_eq!(board.cells.len(), 11); assert_eq!(board.cells.len(), 11);
assert!(board.cells.iter().all(|row| row.len() == 42)); assert!(board.cells.iter().all(|row| row.len() == 42));
for (col, owner, ore_col) in [(2, Players::Player, 1), (39, Players::Enemy, 40)] { for (col, owner, ore_col) in [(2, Players::Player, 1), (39, Players::Enemy, 40)] {
let cell = board.get_ref_cell(5, col); let cell = board.get_ref_cell(5, col);
assert!(matches!(cell.get_structure(), Structures::Base(_))); assert!(matches!(cell.get_structure(), Structures::Base(_)));
assert_eq!(cell.get_structure().get_owner(), owner); assert_eq!(cell.get_structure().get_owner(), owner);
let unit = cell.get_ref_option_unit().as_ref().unwrap(); let unit = cell.get_ref_option_unit().as_ref().unwrap();
assert_eq!(unit.get_coords(), (5, col)); assert_eq!(unit.get_coords(), (5, col));
assert_eq!(unit.get_owner(), owner); assert_eq!(unit.get_owner(), owner);
assert!(matches!( assert!(matches!(
board.get_ref_cell(5, ore_col).get_structure(), board.get_ref_cell(5, ore_col).get_structure(),
Structures::Ore(_) Structures::Ore(_)
)); ));
assert_eq!(
board.get_ref_cell(5, ore_col).get_structure().get_owner(),
Players::Unclaimed
);
}
assert_eq!( assert_eq!(
board.get_ref_cell(5, ore_col).get_structure().get_owner(), board
Players::Unclaimed .cells
.iter()
.flatten()
.filter(|c| c.get_ref_option_unit().is_some())
.count(),
2
); );
} assert!(matches!(
assert_eq!( board.get_ref_cell(0, 0).get_structure(),
board Structures::Stone(_)
.cells ));
.iter()
.flatten()
.filter(|c| c.get_ref_option_unit().is_some())
.count(),
2
);
assert!(matches!(
board.get_ref_cell(0, 0).get_structure(),
Structures::Stone(_)
));
} }
#[test] #[test]
fn focus_cannot_leave_map_even_with_extreme_coordinates() { fn focus_cannot_leave_map_even_with_extreme_coordinates() {
let mut focus = FocusedCell::new(0, 0, 11, 42); let mut focus = FocusedCell::new(0, 0, 11, 42);
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Up), (0, 0)); assert_eq!(focus.move_focused_cell(MoveFocusedCell::Up), (0, 0));
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Left), (0, 0)); assert_eq!(focus.move_focused_cell(MoveFocusedCell::Left), (0, 0));
focus.set_focused_cell((usize::MAX, usize::MAX)); focus.set_focused_cell((usize::MAX, usize::MAX));
assert_eq!(focus.get_coords(), (10, 41)); assert_eq!(focus.get_coords(), (10, 41));
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Down), (10, 41)); assert_eq!(focus.move_focused_cell(MoveFocusedCell::Down), (10, 41));
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Right), (10, 41)); assert_eq!(focus.move_focused_cell(MoveFocusedCell::Right), (10, 41));
} }
#[test] #[test]
fn offsets_remain_within_bounds_when_limits_change() { fn offsets_remain_within_bounds_when_limits_change() {
let mut offset = Offset::new(Some(100), Some(5)); let mut offset = Offset::new(Some(100), Some(5));
assert_eq!(offset.get_value(), 5); assert_eq!(offset.get_value(), 5);
offset.next(); offset.next();
assert_eq!(offset.get_value(), 5); assert_eq!(offset.get_value(), 5);
offset.set_max(2); offset.set_max(2);
assert_eq!(offset.get_value(), 2); assert_eq!(offset.get_value(), 2);
offset.set_value(usize::MAX); offset.set_value(usize::MAX);
assert_eq!(offset.get_value(), 2); assert_eq!(offset.get_value(), 2);
for _ in 0..4 { for _ in 0..4 {
offset.prev(); offset.prev();
} }
assert_eq!(offset.get_value(), 0); assert_eq!(offset.get_value(), 0);
} }
#[test] #[test]
fn confirming_path_queues_task_without_including_start_cell() { fn confirming_path_queues_task_without_including_start_cell() {
let mut board = board(); let mut board = board();
board.change_focused_cell(MoveFocusedCell::Left); board.change_focused_cell(MoveFocusedCell::Left);
board.toggle_marking(); board.toggle_marking();
board.change_focused_cell(MoveFocusedCell::Right); board.change_focused_cell(MoveFocusedCell::Right);
board.change_focused_cell(MoveFocusedCell::Down); board.change_focused_cell(MoveFocusedCell::Down);
board.toggle_marking(); board.toggle_marking();
let unit = board let unit = board
.get_ref_cell(5, 2) .get_ref_cell(5, 2)
.get_ref_option_unit() .get_ref_option_unit()
.as_ref() .as_ref()
.unwrap(); .unwrap();
let tasks = unit.get_ref_tasks(); let tasks = unit.get_ref_tasks();
assert_eq!(tasks.len(), 1); assert_eq!(tasks.len(), 1);
assert_eq!( assert_eq!(
tasks[0].get_path().iter().copied().collect::<Vec<_>>(), tasks[0].get_path().iter().copied().collect::<Vec<_>>(),
[(5, 3), (6, 3)] [(5, 3), (6, 3)]
); );
assert!(!board.marked_cells.marking_cells); assert!(!board.marked_cells.marking_cells);
assert!(board.get_marked_cells().is_empty()); assert!(board.get_marked_cells().is_empty());
assert!(board.cells.iter().flatten().all(|c| !c.get_marked())); assert!(board.cells.iter().flatten().all(|c| !c.get_marked()));
} }
#[test] #[test]
fn cancelling_path_clears_marks_without_queuing_task() { fn cancelling_path_clears_marks_without_queuing_task() {
let mut board = board(); let mut board = board();
board.change_focused_cell(MoveFocusedCell::Left); board.change_focused_cell(MoveFocusedCell::Left);
board.toggle_marking(); board.toggle_marking();
board.change_focused_cell(MoveFocusedCell::Right); board.change_focused_cell(MoveFocusedCell::Right);
board.marked_cells.marking_cells = false; board.marked_cells.marking_cells = false;
board.end_marking_cells(true); board.end_marking_cells(true);
assert!( assert!(
board board
.get_ref_cell(5, 2) .get_ref_cell(5, 2)
.get_ref_option_unit() .get_ref_option_unit()
.as_ref() .as_ref()
.unwrap() .unwrap()
.get_ref_tasks() .get_ref_tasks()
.is_empty() .is_empty()
); );
assert!(board.get_marked_cells().is_empty()); assert!(board.get_marked_cells().is_empty());
assert!(board.marked_cells.selected_unit.is_none()); assert!(board.marked_cells.selected_unit.is_none());
assert!(board.cells.iter().flatten().all(|c| !c.get_marked())); assert!(board.cells.iter().flatten().all(|c| !c.get_marked()));
} }
#[test] #[test]
fn backtracking_revisited_cell_preserves_earlier_mark() { fn backtracking_revisited_cell_preserves_earlier_mark() {
let mut board = board(); let mut board = board();
board.change_focused_cell(MoveFocusedCell::Left); board.change_focused_cell(MoveFocusedCell::Left);
board.toggle_marking(); board.toggle_marking();
board.change_focused_cell(MoveFocusedCell::Right); board.change_focused_cell(MoveFocusedCell::Right);
board.change_focused_cell(MoveFocusedCell::Left); board.change_focused_cell(MoveFocusedCell::Left);
board.pop_marked_cell(); board.pop_marked_cell();
assert_eq!(board.get_focused_cell().get_coords(), (5, 3)); assert_eq!(board.get_focused_cell().get_coords(), (5, 3));
assert!(board.get_ref_cell(5, 2).get_marked()); assert!(board.get_ref_cell(5, 2).get_marked());
board.pop_marked_cell(); board.pop_marked_cell();
board.pop_marked_cell(); board.pop_marked_cell();
assert_eq!(board.get_focused_cell().get_coords(), (5, 2)); assert_eq!(board.get_focused_cell().get_coords(), (5, 2));
assert_eq!(board.get_marked_cells().len(), 1); assert_eq!(board.get_marked_cells().len(), 1);
assert!(!board.get_ref_cell(5, 3).get_marked()); assert!(!board.get_ref_cell(5, 3).get_marked());
} }
#[test] #[test]
fn large_viewport_and_zoom_keep_camera_inside_map() { fn large_viewport_and_zoom_keep_camera_inside_map() {
for zoom in [ for zoom in [
ZoomLevel::ZoomedIn, ZoomLevel::ZoomedIn,
ZoomLevel::Default, ZoomLevel::Default,
ZoomLevel::ZoomedOut, ZoomLevel::ZoomedOut,
] { ] {
let mut board = BoardState::new(&Rect::new(0, 0, 240, 120), 36, 11, zoom, false); let mut board = BoardState::new(&Rect::new(0, 0, 240, 120), 36, 11, zoom, false);
assert_eq!(board.vertical_offset.get_value(), 0); assert_eq!(board.vertical_offset.get_value(), 0);
board.horizontal_offset.set_value(usize::MAX); board.horizontal_offset.set_value(usize::MAX);
board.change_resize(&Rect::new(0, 0, 500, 120), false); board.change_resize(&Rect::new(0, 0, 500, 120), false);
assert_eq!(board.horizontal_offset.get_value(), 0); assert_eq!(board.horizontal_offset.get_value(), 0);
board.change_zoom(ZoomLevel::ZoomedOut); board.change_zoom(ZoomLevel::ZoomedOut);
assert!(board.is_focused_cell_visible()); assert!(board.is_focused_cell_visible());
assert_eq!(board.cells.len(), 11); assert_eq!(board.cells.len(), 11);
} }
} }
+72 -72
View File
@@ -4,94 +4,94 @@ use war_in_tunnels::{app::View, cli::Cli};
#[test] #[test]
fn defaults_start_in_menu_without_logging() { fn defaults_start_in_menu_without_logging() {
let args = Cli::try_parse_from(["game"]).unwrap(); let args = Cli::try_parse_from(["game"]).unwrap();
assert_eq!(args.view, View::MainMenu); assert_eq!(args.view, View::MainMenu);
assert_eq!((args.map_width, args.map_height), (42, 11)); assert_eq!((args.map_width, args.map_height), (42, 11));
assert_eq!(args.username, "Player"); assert_eq!(args.username, "Player");
assert!(!args.log); assert!(!args.log);
assert!(!args.disable_audio); assert!(!args.disable_audio);
assert!(!args.mute); assert!(!args.mute);
} }
#[test] #[test]
fn numeric_options_accept_boundaries_and_reject_out_of_range_values() { fn numeric_options_accept_boundaries_and_reject_out_of_range_values() {
for (flag, min, max) in [ for (flag, min, max) in [
("--map-width", 36, 108), ("--map-width", 36, 108),
("--map-height", 11, 39), ("--map-height", 11, 39),
("--starting-wood", 1, 150), ("--starting-wood", 1, 150),
("--starting-metal", 1, 150), ("--starting-metal", 1, 150),
("--supply-limit", 49, 149), ("--supply-limit", 49, 149),
("--skill-points-limit", 90, 690), ("--skill-points-limit", 90, 690),
] { ] {
for value in [min, max] { for value in [min, max] {
assert!( assert!(
Cli::try_parse_from(["game", flag, &value.to_string()]).is_ok(), Cli::try_parse_from(["game", flag, &value.to_string()]).is_ok(),
"{flag}={value}" "{flag}={value}"
); );
}
for value in [min - 1, max + 1] {
assert!(
Cli::try_parse_from(["game", flag, &value.to_string()]).is_err(),
"{flag}={value}"
);
}
} }
for value in [min - 1, max + 1] {
assert!(
Cli::try_parse_from(["game", flag, &value.to_string()]).is_err(),
"{flag}={value}"
);
}
}
} }
#[test] #[test]
fn xp_modifier_rejects_non_finite_malformed_and_out_of_range_values() { fn xp_modifier_rejects_non_finite_malformed_and_out_of_range_values() {
for value in ["0.5", "1", "2.0"] { for value in ["0.5", "1", "2.0"] {
let args = Cli::try_parse_from(["game", "--xp-modifier", value]).unwrap(); let args = Cli::try_parse_from(["game", "--xp-modifier", value]).unwrap();
assert_eq!(args.xp_modifier, value.parse::<f32>().unwrap()); assert_eq!(args.xp_modifier, value.parse::<f32>().unwrap());
} }
for value in ["0.49", "2.01", "NaN", "inf", "text"] { for value in ["0.49", "2.01", "NaN", "inf", "text"] {
assert!( assert!(
Cli::try_parse_from(["game", "--xp-modifier", value]).is_err(), Cli::try_parse_from(["game", "--xp-modifier", value]).is_err(),
"{value}" "{value}"
); );
} }
} }
#[test] #[test]
fn enum_options_and_audio_flags_are_parsed() { fn enum_options_and_audio_flags_are_parsed() {
let args = let args =
Cli::try_parse_from(["game", "--view", "skirmish", "--disable-audio", "--mute"]).unwrap(); Cli::try_parse_from(["game", "--view", "skirmish", "--disable-audio", "--mute"]).unwrap();
assert_eq!(args.view, View::Skirmish); assert_eq!(args.view, View::Skirmish);
assert!(args.disable_audio && args.mute); assert!(args.disable_audio && args.mute);
for flag in [ for flag in [
"--view", "--view",
"--game-mode", "--game-mode",
"--zoom-level", "--zoom-level",
"--perk-deck", "--perk-deck",
"--soundtrack", "--soundtrack",
] { ] {
assert_eq!( assert_eq!(
Cli::try_parse_from(["game", flag, "unknown"]) Cli::try_parse_from(["game", flag, "unknown"])
.unwrap_err() .unwrap_err()
.kind(), .kind(),
ErrorKind::InvalidValue ErrorKind::InvalidValue
); );
} }
} }
#[test] #[test]
fn binary_help_and_version_work_without_terminal_or_audio() { fn binary_help_and_version_work_without_terminal_or_audio() {
for (flag, expected) in [("--help", "Usage:"), ("--version", "0.1.0")] { for (flag, expected) in [("--help", "Usage:"), ("--version", "0.1.0")] {
let output = Command::new(env!("CARGO_BIN_EXE_war_in_tunnels"))
.arg(flag)
.output()
.unwrap();
assert!(output.status.success());
assert!(String::from_utf8(output.stdout).unwrap().contains(expected));
}
let output = Command::new(env!("CARGO_BIN_EXE_war_in_tunnels")) let output = Command::new(env!("CARGO_BIN_EXE_war_in_tunnels"))
.arg(flag) .args(["--map-width", "0"])
.output() .output()
.unwrap(); .unwrap();
assert!(output.status.success()); assert!(!output.status.success());
assert!(String::from_utf8(output.stdout).unwrap().contains(expected)); assert!(
} String::from_utf8(output.stderr)
let output = Command::new(env!("CARGO_BIN_EXE_war_in_tunnels")) .unwrap()
.args(["--map-width", "0"]) .contains("--map-width")
.output() );
.unwrap();
assert!(!output.status.success());
assert!(
String::from_utf8(output.stderr)
.unwrap()
.contains("--map-width")
);
} }
+336 -333
View File
@@ -1,420 +1,423 @@
use clap::Parser; use clap::Parser;
use ratatui::{ use ratatui::{
Terminal, Terminal,
backend::TestBackend, backend::TestBackend,
buffer::Cell, buffer::Cell,
crossterm::event::{KeyCode, KeyEvent, KeyModifiers}, crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect, layout::Rect,
}; };
use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{ use war_in_tunnels::{
app::{ app::{
Action, App, GameStates, View, available_skirmish_actions, handle_keybindings, Action, App, GameStates, View, available_skirmish_actions, handle_keybindings,
states::{ states::{
SkirmishState, SkirmishState,
skirmish_states::{ skirmish_states::{
BoardState, MoveFocusedCell, Players, Resources, ZoomLevel, BoardState, MoveFocusedCell, Players, Resources, ZoomLevel,
resources::MINE_PRODUCTION_TICKS, resources::MINE_PRODUCTION_TICKS,
structures::{ structures::{
BaseBuilding, BuildingTerrain, Mine, MineConstruction, Ore, Structure, Structures, Tunnel, BaseBuilding, BuildingTerrain, Mine, MineConstruction, Ore, Structure,
Structures, Tunnel,
},
tasks::Tasks,
units::{MinerUnit, Unit, Units},
},
}, },
tasks::Tasks, threads::{AudioCmd, TICK_MS},
units::{MinerUnit, Unit, Units},
},
}, },
threads::{AudioCmd, TICK_MS}, cli::Cli,
},
cli::Cli,
}; };
fn board() -> BoardState { fn board() -> BoardState {
BoardState::new(&Rect::new(0, 0, 160, 50), 42, 11, ZoomLevel::Default, false) BoardState::new(&Rect::new(0, 0, 160, 50), 42, 11, ZoomLevel::Default, false)
} }
fn complete_mine(board: &mut BoardState, coords: (usize, usize)) -> () { fn complete_mine(board: &mut BoardState, coords: (usize, usize)) -> () {
assert!(board.start_mine_construction(coords, Players::Player)); assert!(board.start_mine_construction(coords, Players::Player));
for _ in 0..MineConstruction::BUILD_TICKS { for _ in 0..MineConstruction::BUILD_TICKS {
board.advance_construction(); board.advance_construction();
} }
assert!(matches!( assert!(matches!(
board.cells[coords.0][coords.1].get_structure(), board.cells[coords.0][coords.1].get_structure(),
Structures::Mine(_) Structures::Mine(_)
)); ));
} }
fn app() -> App { fn app() -> App {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap(); let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel(); let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, tx); let mut app: App = App::new(args, tx);
app.view = View::Skirmish; app.view = View::Skirmish;
app.window_area = Rect::new(0, 0, 160, 50); app.window_area = Rect::new(0, 0, 160, 50);
app.states = Some(GameStates::new(&app.args, &app.window_area)); app.states = Some(GameStates::new(&app.args, &app.window_area));
app app
} }
fn travel(board: &mut BoardState, direction: MoveFocusedCell) -> () { fn travel(board: &mut BoardState, direction: MoveFocusedCell) -> () {
board.toggle_marking(); board.toggle_marking();
board.change_focused_cell(direction); board.change_focused_cell(direction);
board.toggle_marking(); board.toggle_marking();
for _ in 0..3000_u16.div_ceil(u16::from(TICK_MS)) { for _ in 0..3000_u16.div_ceil(u16::from(TICK_MS)) {
board.advance_turn(); board.advance_turn();
} }
} }
#[test] #[test]
fn miner_has_builder_permission_through_all_unit_wrappers() -> () { fn miner_has_builder_permission_through_all_unit_wrappers() -> () {
let miner: MinerUnit = MinerUnit::new(Players::Player, (5, 2)); let miner: MinerUnit = MinerUnit::new(Players::Player, (5, 2));
assert!(miner.get_can_build()); assert!(miner.get_can_build());
let unit: Units = Units::Miner(miner); let unit: Units = Units::Miner(miner);
assert!(unit.get_can_build()); assert!(unit.get_can_build());
assert!(Some(unit).get_can_build()); assert!(Some(unit).get_can_build());
let no_unit: Option<Units> = None; let no_unit: Option<Units> = None;
assert!(!no_unit.get_can_build()); assert!(!no_unit.get_can_build());
} }
#[test] #[test]
fn mine_requires_a_cardinal_friendly_builder_and_preserves_target_on_failure() -> () { fn mine_requires_a_cardinal_friendly_builder_and_preserves_target_on_failure() -> () {
let mut board: BoardState = board(); let mut board: BoardState = board();
let original: Structures = board.cells[5][1].get_structure(); let original: Structures = board.cells[5][1].get_structure();
board.cells[5][2].get_mut_option_unit().take(); board.cells[5][2].get_mut_option_unit().take();
assert!(!board.start_mine_construction((5, 1), Players::Player)); assert!(!board.start_mine_construction((5, 1), Players::Player));
*board.cells[5][2].get_mut_option_unit() = *board.cells[5][2].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(Players::Enemy, (5, 2)))); Some(Units::Miner(MinerUnit::new(Players::Enemy, (5, 2))));
assert!(!board.start_mine_construction((5, 1), Players::Player)); assert!(!board.start_mine_construction((5, 1), Players::Player));
board.cells[5][2].get_mut_option_unit().take(); board.cells[5][2].get_mut_option_unit().take();
*board.cells[4][2].get_mut_option_unit() = *board.cells[4][2].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(Players::Player, (4, 2)))); Some(Units::Miner(MinerUnit::new(Players::Player, (4, 2))));
assert!(!board.start_mine_construction((5, 1), Players::Player)); assert!(!board.start_mine_construction((5, 1), Players::Player));
assert_eq!(board.cells[5][1].get_structure(), original); assert_eq!(board.cells[5][1].get_structure(), original);
assert!(!board.start_mine_construction((usize::MAX, 1), Players::Player)); assert!(!board.start_mine_construction((usize::MAX, 1), Players::Player));
assert!(!board.start_mine_construction((5, 1), Players::Unclaimed)); assert!(!board.start_mine_construction((5, 1), Players::Unclaimed));
} }
#[test] #[test]
fn ore_exception_does_not_require_a_base_or_tunnel_connection() -> () { fn ore_exception_does_not_require_a_base_or_tunnel_connection() -> () {
let mut board: BoardState = board(); let mut board: BoardState = board();
board.cells[0][0].set_structure(Structures::Ore(Ore::new(Players::Unclaimed, 1))); board.cells[0][0].set_structure(Structures::Ore(Ore::new(Players::Unclaimed, 1)));
*board.cells[0][1].get_mut_option_unit() = *board.cells[0][1].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(Players::Player, (0, 1)))); Some(Units::Miner(MinerUnit::new(Players::Player, (0, 1))));
assert!(board.can_build_at((0, 0), Players::Player, BuildingTerrain::Ore)); assert!(board.can_build_at((0, 0), Players::Player, BuildingTerrain::Ore));
complete_mine(&mut board, (0, 0)); complete_mine(&mut board, (0, 0));
assert_eq!( assert_eq!(
board.cells[0][0].get_structure().get_owner(), board.cells[0][0].get_structure().get_owner(),
Players::Player Players::Player
); );
assert!(!board.start_mine_construction((0, 0), Players::Player)); assert!(!board.start_mine_construction((0, 0), Players::Player));
} }
#[test] #[test]
fn ordinary_building_sites_require_stone_connected_to_base_or_tunnel() -> () { fn ordinary_building_sites_require_stone_connected_to_base_or_tunnel() -> () {
let mut board: BoardState = board(); let mut board: BoardState = board();
assert!(board.can_build_at((5, 3), Players::Player, BuildingTerrain::Stone)); assert!(board.can_build_at((5, 3), Players::Player, BuildingTerrain::Stone));
assert!(!board.can_build_at((5, 3), Players::Player, BuildingTerrain::Ore)); assert!(!board.can_build_at((5, 3), Players::Player, BuildingTerrain::Ore));
*board.cells[5][5].get_mut_option_unit() = *board.cells[5][5].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(Players::Player, (5, 5)))); Some(Units::Miner(MinerUnit::new(Players::Player, (5, 5))));
assert!(!board.can_build_at((5, 4), Players::Player, BuildingTerrain::Stone)); assert!(!board.can_build_at((5, 4), Players::Player, BuildingTerrain::Stone));
board.cells[5][5].set_structure(Structures::Tunnel(Tunnel::new())); board.cells[5][5].set_structure(Structures::Tunnel(Tunnel::new()));
assert!(board.can_build_at((5, 4), Players::Player, BuildingTerrain::Stone)); assert!(board.can_build_at((5, 4), Players::Player, BuildingTerrain::Stone));
board.cells[5][5].set_structure(Structures::Base(BaseBuilding::new(Players::Enemy))); board.cells[5][5].set_structure(Structures::Base(BaseBuilding::new(Players::Enemy)));
assert!(!board.can_build_at((5, 4), Players::Player, BuildingTerrain::Stone)); assert!(!board.can_build_at((5, 4), Players::Player, BuildingTerrain::Stone));
board.cells[5][3].set_structure(Structures::Tunnel(Tunnel::new())); board.cells[5][3].set_structure(Structures::Tunnel(Tunnel::new()));
assert!(!board.can_build_at((5, 3), Players::Player, BuildingTerrain::Stone)); assert!(!board.can_build_at((5, 3), Players::Player, BuildingTerrain::Stone));
} }
#[test] #[test]
fn occupied_depleted_or_claimed_deposit_cannot_be_built_on() -> () { fn occupied_depleted_or_claimed_deposit_cannot_be_built_on() -> () {
let mut board: BoardState = board(); let mut board: BoardState = board();
*board.cells[5][1].get_mut_option_unit() = *board.cells[5][1].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(Players::Player, (5, 1)))); Some(Units::Miner(MinerUnit::new(Players::Player, (5, 1))));
assert!(!board.start_mine_construction((5, 1), Players::Player)); assert!(!board.start_mine_construction((5, 1), Players::Player));
board.cells[5][1].get_mut_option_unit().take(); board.cells[5][1].get_mut_option_unit().take();
let mut ore: Ore = Ore::new(Players::Unclaimed, 1); let mut ore: Ore = Ore::new(Players::Unclaimed, 1);
ore.extract(5000); ore.extract(5000);
board.cells[5][1].set_structure(Structures::Ore(ore)); board.cells[5][1].set_structure(Structures::Ore(ore));
assert!(!board.start_mine_construction((5, 1), Players::Player)); assert!(!board.start_mine_construction((5, 1), Players::Player));
board.cells[5][1].set_structure(Structures::Ore(Ore::new(Players::Enemy, 1))); board.cells[5][1].set_structure(Structures::Ore(Ore::new(Players::Enemy, 1)));
assert!(!board.start_mine_construction((5, 1), Players::Player)); assert!(!board.start_mine_construction((5, 1), Players::Player));
} }
#[test] #[test]
fn construction_view_starts_paid_build_on_enter_and_stays_open() -> () { fn construction_view_starts_paid_build_on_enter_and_stays_open() -> () {
let mut app: App = app(); let mut app: App = app();
for _ in 0..2 { for _ in 0..2 {
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); handle_keybindings(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
} }
handle_keybindings( handle_keybindings(
&mut app, &mut app,
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL),
); );
assert!(available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Enter)); assert!(
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 50)).unwrap(); available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Enter)
terminal );
.draw(|frame: &mut ratatui::Frame<'_>| -> () { let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 50)).unwrap();
frame.render_widget(&mut app, frame.area()); terminal
}) .draw(|frame: &mut ratatui::Frame<'_>| -> () {
.unwrap(); frame.render_widget(&mut app, frame.area());
let text: String = terminal })
.backend() .unwrap();
.buffer() let text: String = terminal
.content .backend()
.iter() .buffer()
.map(|cell: &Cell| -> &str { cell.symbol() }) .content
.collect(); .iter()
assert!(text.contains("Ready to build: Enter")); .map(|cell: &Cell| -> &str { cell.symbol() })
assert!(text.contains("Enter - Build Mine")); .collect();
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(text.contains("Ready to build: Enter"));
let state: &SkirmishState = &app.states_ref().unwrap().skirmish; assert!(text.contains("Enter - Build Mine"));
assert!(state.building_view); handle_keybindings(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!( let state: &SkirmishState = &app.states_ref().unwrap().skirmish;
state.board.cells[5][1].get_structure(), assert!(state.building_view);
Structures::MineConstruction(_) assert!(matches!(
)); state.board.cells[5][1].get_structure(),
assert_eq!(state.player_resources.wood, 30); Structures::MineConstruction(_)
assert_eq!(state.player_resources.metal, 15); ));
assert_eq!(state.player_resources.wood, 30);
assert_eq!(state.player_resources.metal, 15);
} }
#[test] #[test]
fn miner_can_travel_into_and_out_of_friendly_mine_with_moving_tasks() -> () { fn miner_can_travel_into_and_out_of_friendly_mine_with_moving_tasks() -> () {
let mut board: BoardState = board(); let mut board: BoardState = board();
complete_mine(&mut board, (5, 1)); complete_mine(&mut board, (5, 1));
board.change_focused_cell(MoveFocusedCell::Left); board.change_focused_cell(MoveFocusedCell::Left);
board.toggle_marking(); board.toggle_marking();
board.change_focused_cell(MoveFocusedCell::Left); board.change_focused_cell(MoveFocusedCell::Left);
board.toggle_marking(); board.toggle_marking();
let unit: &Units = board.cells[5][2].get_ref_option_unit().as_ref().unwrap(); let unit: &Units = board.cells[5][2].get_ref_option_unit().as_ref().unwrap();
assert!(matches!( assert!(matches!(
unit.get_ref_tasks().front(), unit.get_ref_tasks().front(),
Some(Tasks::Moving(_)) Some(Tasks::Moving(_))
)); ));
for _ in 1..3000_u16.div_ceil(u16::from(TICK_MS)) { for _ in 1..3000_u16.div_ceil(u16::from(TICK_MS)) {
board.advance_turn();
}
assert!(board.cells[5][1].get_ref_option_unit().is_none());
board.advance_turn(); board.advance_turn();
} assert_eq!(board.cells[5][1].get_ref_option_unit().get_coords(), (5, 1));
assert!(board.cells[5][1].get_ref_option_unit().is_none()); assert!(board.cells[5][2].get_ref_option_unit().is_none());
board.advance_turn(); travel(&mut board, MoveFocusedCell::Right);
assert_eq!(board.cells[5][1].get_ref_option_unit().get_coords(), (5, 1)); assert!(board.cells[5][1].get_ref_option_unit().is_none());
assert!(board.cells[5][2].get_ref_option_unit().is_none()); assert_eq!(board.cells[5][2].get_ref_option_unit().get_coords(), (5, 2));
travel(&mut board, MoveFocusedCell::Right);
assert!(board.cells[5][1].get_ref_option_unit().is_none());
assert_eq!(board.cells[5][2].get_ref_option_unit().get_coords(), (5, 2));
} }
#[test] #[test]
fn staffed_mine_doubles_production_and_bonus_stops_after_departure() -> () { fn staffed_mine_doubles_production_and_bonus_stops_after_departure() -> () {
let mut app: App = app(); let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
complete_mine(&mut game.board, (5, 1)); complete_mine(&mut game.board, (5, 1));
game.turn_counter = MINE_PRODUCTION_TICKS - 1; game.turn_counter = MINE_PRODUCTION_TICKS - 1;
game.tick_update(); game.tick_update();
assert_eq!(game.player_resources.metal, 26); assert_eq!(game.player_resources.metal, 26);
game.board.change_focused_cell(MoveFocusedCell::Left); game.board.change_focused_cell(MoveFocusedCell::Left);
travel(&mut game.board, MoveFocusedCell::Left); travel(&mut game.board, MoveFocusedCell::Left);
game.turn_counter = MINE_PRODUCTION_TICKS - 1; game.turn_counter = MINE_PRODUCTION_TICKS - 1;
game.tick_update(); game.tick_update();
assert_eq!(game.player_resources.metal, 28); assert_eq!(game.player_resources.metal, 28);
travel(&mut game.board, MoveFocusedCell::Right); travel(&mut game.board, MoveFocusedCell::Right);
game.turn_counter = MINE_PRODUCTION_TICKS - 1; game.turn_counter = MINE_PRODUCTION_TICKS - 1;
game.tick_update(); game.tick_update();
assert_eq!(game.player_resources.metal, 29); assert_eq!(game.player_resources.metal, 29);
let Structures::Mine(mine) = game.board.cells[5][1].get_structure() else { let Structures::Mine(mine) = game.board.cells[5][1].get_structure() else {
panic!("mine missing"); panic!("mine missing");
}; };
assert_eq!(mine.get_ore_amount(), 4996); assert_eq!(mine.get_ore_amount(), 4996);
} }
#[test] #[test]
fn mine_rejects_enemy_miners_and_staffing_does_not_overdraw_ore() -> () { fn mine_rejects_enemy_miners_and_staffing_does_not_overdraw_ore() -> () {
let mut ore: Ore = Ore::new(Players::Unclaimed, 1); let mut ore: Ore = Ore::new(Players::Unclaimed, 1);
ore.extract(4999); ore.extract(4999);
let mut mine: Mine = Mine::new(Players::Player, ore).unwrap(); let mut mine: Mine = Mine::new(Players::Player, ore).unwrap();
let player: Units = Units::Miner(MinerUnit::new(Players::Player, (5, 2))); let player: Units = Units::Miner(MinerUnit::new(Players::Player, (5, 2)));
let enemy: Units = Units::Miner(MinerUnit::new(Players::Enemy, (5, 2))); let enemy: Units = Units::Miner(MinerUnit::new(Players::Enemy, (5, 2)));
assert!(mine.accepts_unit(&player)); assert!(mine.accepts_unit(&player));
assert!(!mine.accepts_unit(&enemy)); assert!(!mine.accepts_unit(&enemy));
assert!(!Structures::Mine(mine).can_enter(&enemy)); assert!(!Structures::Mine(mine).can_enter(&enemy));
assert_eq!(mine.produce_with_miner(true), 1); assert_eq!(mine.produce_with_miner(true), 1);
assert_eq!(mine.produce_with_miner(true), 0); assert_eq!(mine.produce_with_miner(true), 0);
} }
#[test] #[test]
fn remote_ore_cannot_start_construction_or_consume_resources() -> () { fn remote_ore_cannot_start_construction_or_consume_resources() -> () {
let mut app: App = app(); let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
let ore: Structures = Structures::Ore(Ore::new(Players::Unclaimed, 1)); let ore: Structures = Structures::Ore(Ore::new(Players::Unclaimed, 1));
game.board.cells[5][21].set_structure(ore); game.board.cells[5][21].set_structure(ore);
let before: Resources = game.player_resources; let before: Resources = game.player_resources;
assert!(!game.start_mine_construction((5, 21), Players::Player)); assert!(!game.start_mine_construction((5, 21), Players::Player));
assert_eq!(game.player_resources, before); assert_eq!(game.player_resources, before);
assert_eq!(game.board.cells[5][21].get_structure(), ore); assert_eq!(game.board.cells[5][21].get_structure(), ore);
} }
#[test] #[test]
fn construction_payment_is_atomic_and_exact_funds_are_enough() -> () { fn construction_payment_is_atomic_and_exact_funds_are_enough() -> () {
let mut app: App = app(); let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
for resources in [ for resources in [
Resources { Resources {
wood: 19, wood: 19,
metal: 10, metal: 10,
}, },
Resources { wood: 20, metal: 9 }, Resources { wood: 20, metal: 9 },
] { ] {
game.player_resources = resources; game.player_resources = resources;
assert!(!game.can_start_mine_construction((5, 1), Players::Player)); assert!(!game.can_start_mine_construction((5, 1), Players::Player));
assert!(!game.start_mine_construction((5, 1), Players::Player));
assert_eq!(game.player_resources, resources);
assert!(matches!(
game.board.cells[5][1].get_structure(),
Structures::Ore(_)
));
}
game.player_resources = Mine::COST;
assert!(game.start_mine_construction((5, 1), Players::Player));
assert_eq!(game.player_resources, Resources { wood: 0, metal: 0 });
assert!(!game.start_mine_construction((5, 1), Players::Player)); assert!(!game.start_mine_construction((5, 1), Players::Player));
assert_eq!(game.player_resources, resources);
assert!(matches!(
game.board.cells[5][1].get_structure(),
Structures::Ore(_)
));
}
game.player_resources = Mine::COST;
assert!(game.start_mine_construction((5, 1), Players::Player));
assert_eq!(game.player_resources, Resources { wood: 0, metal: 0 });
assert!(!game.start_mine_construction((5, 1), Players::Player));
} }
#[test] #[test]
fn construction_takes_ten_seconds_and_cannot_produce_or_accept_miners_early() -> () { fn construction_takes_ten_seconds_and_cannot_produce_or_accept_miners_early() -> () {
let mut app: App = app(); let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
assert!(game.start_mine_construction((5, 1), Players::Player)); assert!(game.start_mine_construction((5, 1), Players::Player));
for _ in 1..MineConstruction::BUILD_TICKS { for _ in 1..MineConstruction::BUILD_TICKS {
game.tick_update();
}
let structure: Structures = game.board.cells[5][1].get_structure();
let Structures::MineConstruction(site) = structure else {
panic!("completed too early");
};
assert_eq!(site.progress_percent(), 99);
assert!(site.into_mine().is_none());
assert_eq!(site.cancel().get_ore_amount(), 5000);
assert!(
!structure.can_enter(
game.board.cells[5][2]
.get_ref_option_unit()
.as_ref()
.unwrap()
)
);
assert_eq!(game.player_resources.metal, 15);
game.tick_update(); game.tick_update();
} assert!(matches!(
let structure: Structures = game.board.cells[5][1].get_structure(); game.board.cells[5][1].get_structure(),
let Structures::MineConstruction(site) = structure else { Structures::Mine(_)
panic!("completed too early"); ));
}; assert_eq!(game.player_resources.metal, 15);
assert_eq!(site.progress_percent(), 99);
assert!(site.into_mine().is_none());
assert_eq!(site.cancel().get_ore_amount(), 5000);
assert!(
!structure.can_enter(
game.board.cells[5][2]
.get_ref_option_unit()
.as_ref()
.unwrap()
)
);
assert_eq!(game.player_resources.metal, 15);
game.tick_update();
assert!(matches!(
game.board.cells[5][1].get_structure(),
Structures::Mine(_)
));
assert_eq!(game.player_resources.metal, 15);
} }
#[test] #[test]
fn construction_pauses_without_nearby_builder_and_resumes_after_return() -> () { fn construction_pauses_without_nearby_builder_and_resumes_after_return() -> () {
let mut app: App = app(); let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
assert!(game.start_mine_construction((5, 1), Players::Player)); assert!(game.start_mine_construction((5, 1), Players::Player));
for _ in 0..100 { for _ in 0..100 {
game.tick_update(); game.tick_update();
} }
let before: Structures = game.board.cells[5][1].get_structure(); let before: Structures = game.board.cells[5][1].get_structure();
let builder: Option<Units> = game.board.cells[5][2].get_mut_option_unit().take(); let builder: Option<Units> = game.board.cells[5][2].get_mut_option_unit().take();
for _ in 0..MineConstruction::BUILD_TICKS { for _ in 0..MineConstruction::BUILD_TICKS {
game.tick_update(); game.tick_update();
} }
assert_eq!(game.board.cells[5][1].get_structure(), before); assert_eq!(game.board.cells[5][1].get_structure(), before);
assert_eq!(game.player_resources.metal, 15); assert_eq!(game.player_resources.metal, 15);
*game.board.cells[5][2].get_mut_option_unit() = builder; *game.board.cells[5][2].get_mut_option_unit() = builder;
for _ in 100..MineConstruction::BUILD_TICKS { for _ in 100..MineConstruction::BUILD_TICKS {
game.tick_update(); game.tick_update();
} }
assert!(matches!( assert!(matches!(
game.board.cells[5][1].get_structure(), game.board.cells[5][1].get_structure(),
Structures::Mine(_) Structures::Mine(_)
)); ));
} }
#[test] #[test]
fn cancellation_restores_original_ore_and_refunds_cost_exactly_once() -> () { fn cancellation_restores_original_ore_and_refunds_cost_exactly_once() -> () {
let mut app: App = app(); let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
let mut ore: Ore = Ore::new(Players::Unclaimed, 1); let mut ore: Ore = Ore::new(Players::Unclaimed, 1);
ore.extract(123); ore.extract(123);
game.board.cells[5][1].set_structure(Structures::Ore(ore)); game.board.cells[5][1].set_structure(Structures::Ore(ore));
let before: Resources = game.player_resources; let before: Resources = game.player_resources;
assert!(game.start_mine_construction((5, 1), Players::Player)); assert!(game.start_mine_construction((5, 1), Players::Player));
for _ in 0..100 { for _ in 0..100 {
game.tick_update(); game.tick_update();
} }
game.board.cells[5][2].get_mut_option_unit().take(); game.board.cells[5][2].get_mut_option_unit().take();
assert!(game.cancel_mine_construction((5, 1), Players::Player)); assert!(game.cancel_mine_construction((5, 1), Players::Player));
assert_eq!(game.player_resources, before); assert_eq!(game.player_resources, before);
assert_eq!(game.board.cells[5][1].get_structure(), Structures::Ore(ore)); assert_eq!(game.board.cells[5][1].get_structure(), Structures::Ore(ore));
assert!(!game.cancel_mine_construction((5, 1), Players::Player)); assert!(!game.cancel_mine_construction((5, 1), Players::Player));
assert_eq!(game.player_resources, before); assert_eq!(game.player_resources, before);
} }
#[test] #[test]
fn panel_shows_progress_and_delete_cancels_while_escape_only_closes_panel() -> () { fn panel_shows_progress_and_delete_cancels_while_escape_only_closes_panel() -> () {
let mut app: App = app(); let mut app: App = app();
for _ in 0..2 { for _ in 0..2 {
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); handle_keybindings(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
} }
handle_keybindings( handle_keybindings(
&mut app, &mut app,
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL),
); );
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); handle_keybindings(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!( assert!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Delete) available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Delete)
); );
assert!( assert!(
!available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Enter) !available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Enter)
); );
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); handle_keybindings(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.states_ref().unwrap().skirmish.player_resources.wood, 30); assert_eq!(app.states_ref().unwrap().skirmish.player_resources.wood, 30);
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 50)).unwrap(); let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 50)).unwrap();
terminal terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () { .draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area()); frame.render_widget(&mut app, frame.area());
}) })
.unwrap(); .unwrap();
let text: String = terminal let text: String = terminal
.backend() .backend()
.buffer() .buffer()
.content .content
.iter() .iter()
.map(|cell: &Cell| -> &str { cell.symbol() }) .map(|cell: &Cell| -> &str { cell.symbol() })
.collect(); .collect();
assert!(text.contains("Cost: 20 Wood + 10 Metal")); assert!(text.contains("Cost: 20 Wood + 10 Metal"));
assert!(text.contains("Construction: 0%")); assert!(text.contains("Construction: 0%"));
assert!(text.contains("Delete - Cancel construction")); assert!(text.contains("Delete - Cancel construction"));
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); handle_keybindings(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!( assert!(matches!(
app.states_ref().unwrap().skirmish.board.cells[5][1].get_structure(), app.states_ref().unwrap().skirmish.board.cells[5][1].get_structure(),
Structures::MineConstruction(_) Structures::MineConstruction(_)
)); ));
handle_keybindings( handle_keybindings(
&mut app, &mut app,
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL),
); );
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); handle_keybindings(&mut app, KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
assert!(matches!( assert!(matches!(
app.states_ref().unwrap().skirmish.board.cells[5][1].get_structure(), app.states_ref().unwrap().skirmish.board.cells[5][1].get_structure(),
Structures::Ore(_) Structures::Ore(_)
)); ));
assert_eq!( assert_eq!(
app.states_ref().unwrap().skirmish.player_resources, app.states_ref().unwrap().skirmish.player_resources,
Resources::new(50, 25) Resources::new(50, 25)
); );
} }
#[test] #[test]
fn player_cannot_cancel_enemy_site_or_refund_a_completed_mine() -> () { fn player_cannot_cancel_enemy_site_or_refund_a_completed_mine() -> () {
let mut app: App = app(); let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
assert!(game.start_mine_construction((5, 40), Players::Enemy)); assert!(game.start_mine_construction((5, 40), Players::Enemy));
assert!(!game.cancel_mine_construction((5, 40), Players::Player)); assert!(!game.cancel_mine_construction((5, 40), Players::Player));
assert_eq!(game.player_resources, Resources::new(50, 25)); assert_eq!(game.player_resources, Resources::new(50, 25));
for _ in 0..MineConstruction::BUILD_TICKS { for _ in 0..MineConstruction::BUILD_TICKS {
game.tick_update(); game.tick_update();
} }
assert!(!game.cancel_mine_construction((5, 40), Players::Enemy)); assert!(!game.cancel_mine_construction((5, 40), Players::Enemy));
assert_eq!(game.enemy_resources.metal, 15); assert_eq!(game.enemy_resources.metal, 15);
} }
+131 -131
View File
@@ -1,160 +1,160 @@
use clap::Parser; use clap::Parser;
use ratatui::{ use ratatui::{
Terminal, Terminal,
backend::TestBackend, backend::TestBackend,
buffer::Cell, buffer::Cell,
crossterm::event::{KeyCode, KeyEvent, KeyModifiers}, crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect, layout::Rect,
}; };
use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{ use war_in_tunnels::{
app::{ app::{
Action, App, GameStates, View, available_skirmish_actions, handle_keybindings, Action, App, GameStates, View, available_skirmish_actions, handle_keybindings,
states::skirmish_states::structures::Structures, threads::AudioCmd, states::skirmish_states::structures::Structures, threads::AudioCmd,
}, },
cli::Cli, cli::Cli,
}; };
fn app() -> App { fn app() -> App {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap(); let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel(); let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, tx); let mut app: App = App::new(args, tx);
app.view = View::Skirmish; app.view = View::Skirmish;
app.window_area = Rect::new(0, 0, 160, 40); app.window_area = Rect::new(0, 0, 160, 40);
app.states = Some(GameStates::new(&app.args, &app.window_area)); app.states = Some(GameStates::new(&app.args, &app.window_area));
app app
} }
fn press(app: &mut App, code: KeyCode, modifiers: KeyModifiers) -> () { fn press(app: &mut App, code: KeyCode, modifiers: KeyModifiers) -> () {
handle_keybindings(app, KeyEvent::new(code, modifiers)); handle_keybindings(app, KeyEvent::new(code, modifiers));
} }
#[test] #[test]
fn selecting_miner_switches_help_and_escape_cancels_only_the_route() -> () { fn selecting_miner_switches_help_and_escape_cancels_only_the_route() -> () {
let mut app: App = app(); let mut app: App = app();
assert!( assert!(
!available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Space) !available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Space)
); );
press(&mut app, KeyCode::Left, KeyModifiers::NONE); press(&mut app, KeyCode::Left, KeyModifiers::NONE);
assert!(available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Space)); assert!(
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Space)
let actions: Vec<Action> = available_skirmish_actions(&app.states_ref().unwrap().skirmish); );
assert!(actions.contains(&Action::Backspace)); press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
assert!(actions.contains(&Action::Delete)); let actions: Vec<Action> = available_skirmish_actions(&app.states_ref().unwrap().skirmish);
assert!(!actions.contains(&Action::Building)); assert!(actions.contains(&Action::Backspace));
assert!(!actions.contains(&Action::ZoomIn)); assert!(actions.contains(&Action::Delete));
press(&mut app, KeyCode::Char(' '), KeyModifiers::CONTROL); assert!(!actions.contains(&Action::Building));
assert!(!app.states_ref().unwrap().skirmish.building_view); assert!(!actions.contains(&Action::ZoomIn));
press(&mut app, KeyCode::Esc, KeyModifiers::NONE); press(&mut app, KeyCode::Char(' '), KeyModifiers::CONTROL);
assert_eq!(app.view, View::Skirmish); assert!(!app.states_ref().unwrap().skirmish.building_view);
assert!( press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
!app assert_eq!(app.view, View::Skirmish);
.states_ref() assert!(
.unwrap() !app.states_ref()
.skirmish .unwrap()
.board .skirmish
.marked_cells .board
.marking_cells .marked_cells
); .marking_cells
assert!( );
app assert!(
.states_ref() app.states_ref()
.unwrap() .unwrap()
.skirmish .skirmish
.board .board
.get_marked_cells() .get_marked_cells()
.is_empty() .is_empty()
); );
} }
#[test] #[test]
fn building_view_has_its_own_help_and_does_not_build_on_invalid_tile() -> () { fn building_view_has_its_own_help_and_does_not_build_on_invalid_tile() -> () {
let mut app: App = app(); let mut app: App = app();
let before: war_in_tunnels::app::states::skirmish_states::BoardState = let before: war_in_tunnels::app::states::skirmish_states::BoardState =
app.states_ref().unwrap().skirmish.board.clone(); app.states_ref().unwrap().skirmish.board.clone();
press(&mut app, KeyCode::Char(' '), KeyModifiers::CONTROL); press(&mut app, KeyCode::Char(' '), KeyModifiers::CONTROL);
assert!(app.states_ref().unwrap().skirmish.building_view); assert!(app.states_ref().unwrap().skirmish.building_view);
assert_eq!( assert_eq!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish), available_skirmish_actions(&app.states_ref().unwrap().skirmish),
[Action::Building, Action::Esc, Action::Quit2] [Action::Building, Action::Esc, Action::Quit2]
); );
press(&mut app, KeyCode::Left, KeyModifiers::NONE); press(&mut app, KeyCode::Left, KeyModifiers::NONE);
press(&mut app, KeyCode::Enter, KeyModifiers::NONE); press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
assert_eq!(app.states_ref().unwrap().skirmish.board, before); assert_eq!(app.states_ref().unwrap().skirmish.board, before);
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 40)).unwrap(); let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 40)).unwrap();
terminal terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () { .draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area()); frame.render_widget(&mut app, frame.area());
}) })
.unwrap(); .unwrap();
let text: String = terminal let text: String = terminal
.backend() .backend()
.buffer() .buffer()
.content .content
.iter() .iter()
.map(|cell: &Cell| -> &str { cell.symbol() }) .map(|cell: &Cell| -> &str { cell.symbol() })
.collect(); .collect();
assert!(text.contains("Construction unavailable")); assert!(text.contains("Construction unavailable"));
assert!(text.contains("Close buildings")); assert!(text.contains("Close buildings"));
assert!(!text.contains("Marking cells")); assert!(!text.contains("Marking cells"));
press(&mut app, KeyCode::Esc, KeyModifiers::NONE); press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.building_view); assert!(!app.states_ref().unwrap().skirmish.building_view);
assert_eq!(app.view, View::Skirmish); assert_eq!(app.view, View::Skirmish);
for row in &app.states_ref().unwrap().skirmish.board.cells { for row in &app.states_ref().unwrap().skirmish.board.cells {
for cell in row { for cell in row {
assert!(!matches!(cell.get_structure(), Structures::Mine(_))); assert!(!matches!(cell.get_structure(), Structures::Mine(_)));
}
} }
}
} }
#[test] #[test]
fn legacy_control_space_and_quit_work_in_building_view() -> () { fn legacy_control_space_and_quit_work_in_building_view() -> () {
let mut app: App = app(); let mut app: App = app();
press(&mut app, KeyCode::Null, KeyModifiers::NONE); press(&mut app, KeyCode::Null, KeyModifiers::NONE);
assert!(app.states_ref().unwrap().skirmish.building_view); assert!(app.states_ref().unwrap().skirmish.building_view);
press(&mut app, KeyCode::Null, KeyModifiers::CONTROL); press(&mut app, KeyCode::Null, KeyModifiers::CONTROL);
assert!(!app.states_ref().unwrap().skirmish.building_view); assert!(!app.states_ref().unwrap().skirmish.building_view);
press(&mut app, KeyCode::Null, KeyModifiers::NONE); press(&mut app, KeyCode::Null, KeyModifiers::NONE);
press(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL); press(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(app.exit); assert!(app.exit);
} }
#[test] #[test]
fn contextual_help_fits_normal_terminal_and_changes_when_route_is_selected() -> () { fn contextual_help_fits_normal_terminal_and_changes_when_route_is_selected() -> () {
let mut app: App = app(); let mut app: App = app();
app.window_area = Rect::new(0, 0, 120, 40); app.window_area = Rect::new(0, 0, 120, 40);
press(&mut app, KeyCode::Left, KeyModifiers::NONE); press(&mut app, KeyCode::Left, KeyModifiers::NONE);
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(120, 40)).unwrap(); let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(120, 40)).unwrap();
terminal terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () { .draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area()); frame.render_widget(&mut app, frame.area());
}) })
.unwrap(); .unwrap();
let text: String = terminal let text: String = terminal
.backend() .backend()
.buffer() .buffer()
.content .content
.iter() .iter()
.map(|cell: &Cell| -> &str { cell.symbol() }) .map(|cell: &Cell| -> &str { cell.symbol() })
.collect(); .collect();
assert!(text.contains("Ctrl + Space - Build")); assert!(text.contains("Ctrl + Space - Build"));
assert!(text.contains("Space - Select miner")); assert!(text.contains("Space - Select miner"));
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
terminal terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () { .draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area()); frame.render_widget(&mut app, frame.area());
}) })
.unwrap(); .unwrap();
let text: String = terminal let text: String = terminal
.backend() .backend()
.buffer() .buffer()
.content .content
.iter() .iter()
.map(|cell: &Cell| -> &str { cell.symbol() }) .map(|cell: &Cell| -> &str { cell.symbol() })
.collect(); .collect();
assert!(text.contains("Space - Confirm route")); assert!(text.contains("Space - Confirm route"));
assert!(text.contains("Esc - Cancel route")); assert!(text.contains("Esc - Cancel route"));
assert!(!text.contains("Ctrl + Space")); assert!(!text.contains("Ctrl + Space"));
assert!(!text.contains("Zoom in")); assert!(!text.contains("Zoom in"));
} }
+427 -427
View File
@@ -1,534 +1,534 @@
use clap::Parser; use clap::Parser;
use ratatui::{ use ratatui::{
Terminal, Terminal,
backend::TestBackend, backend::TestBackend,
buffer::Buffer, buffer::Buffer,
crossterm::event::{KeyCode, KeyEvent, KeyModifiers}, crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect, layout::Rect,
widgets::Widget, widgets::Widget,
}; };
use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{ use war_in_tunnels::{
app::{ app::{
App, GameStates, View, handle_keybindings, App, GameStates, View, handle_keybindings,
states::{ states::{
SkirmishState, SkirmishState,
skirmish_states::{ skirmish_states::{
BoardState, CellSizes, MoveFocusedCell, Players, ZoomLevel, BoardState, CellSizes, MoveFocusedCell, Players, ZoomLevel,
structures::{Structure, Structures, Tunnel}, structures::{Structure, Structures, Tunnel},
tasks::{DiggingTask, MovingTask, Task, Tasks}, tasks::{DiggingTask, MovingTask, Task, Tasks},
units::{MinerUnit, Unit, Units}, units::{MinerUnit, Unit, Units},
}, },
},
threads::{AudioCmd, TICK_MS},
}, },
threads::{AudioCmd, TICK_MS}, cli::Cli,
},
cli::Cli,
}; };
fn game() -> SkirmishState { fn game() -> SkirmishState {
let args = Cli::try_parse_from(["game", "--disable-audio"]).unwrap(); let args = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
GameStates::new(&args, &Rect::new(0, 0, 120, 40)).skirmish GameStates::new(&args, &Rect::new(0, 0, 120, 40)).skirmish
} }
fn queue(board: &mut BoardState, origin: (usize, usize), path: &[(usize, usize)]) { fn queue(board: &mut BoardState, origin: (usize, usize), path: &[(usize, usize)]) {
let unit = board.cells[origin.0][origin.1] let unit = board.cells[origin.0][origin.1]
.get_mut_option_unit() .get_mut_option_unit()
.as_mut() .as_mut()
.unwrap(); .unwrap();
let power = unit.get_digging_power(); let power = unit.get_digging_power();
unit.set_task(Tasks::Digging(DiggingTask::new( unit.set_task(Tasks::Digging(DiggingTask::new(
path.iter().copied().collect(), path.iter().copied().collect(),
power, power,
))); )));
} }
fn tunnel(board: &mut BoardState, row: usize, col: usize) { fn tunnel(board: &mut BoardState, row: usize, col: usize) {
board.cells[row][col].set_structure(Structures::Tunnel(Tunnel::new())); board.cells[row][col].set_structure(Structures::Tunnel(Tunnel::new()));
} }
fn unit(board: &BoardState, row: usize, col: usize) -> &Units { fn unit(board: &BoardState, row: usize, col: usize) -> &Units {
board.cells[row][col] board.cells[row][col]
.get_ref_option_unit() .get_ref_option_unit()
.as_ref() .as_ref()
.unwrap() .unwrap()
} }
fn travel_ticks() -> u16 { fn travel_ticks() -> u16 {
3000_u16.div_ceil(u16::from(TICK_MS)) 3000_u16.div_ceil(u16::from(TICK_MS))
} }
fn advance(game: &mut SkirmishState, ticks: u16) { fn advance(game: &mut SkirmishState, ticks: u16) {
for _ in 0..ticks { for _ in 0..ticks {
game.tick_update(); game.tick_update();
} }
} }
#[test] #[test]
fn tick_damages_stone_before_moving_and_finishes_entire_route() { fn tick_damages_stone_before_moving_and_finishes_entire_route() {
let mut game = game(); let mut game = game();
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]); queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
game.tick_update();
assert_eq!(game.turn_counter, 1);
let durability = game.board.cells[5][3].get_structure().get_durability();
assert!(durability < 1000 && durability > 0);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
assert!(game.board.cells[5][3].get_ref_option_unit().is_none());
for _ in 0..500 {
game.tick_update(); game.tick_update();
} assert_eq!(game.turn_counter, 1);
for col in [3, 4] { let durability = game.board.cells[5][3].get_structure().get_durability();
assert!(durability < 1000 && durability > 0);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
assert!(game.board.cells[5][3].get_ref_option_unit().is_none());
for _ in 0..500 {
game.tick_update();
}
for col in [3, 4] {
assert!(matches!(
game.board.cells[5][col].get_structure(),
Structures::Tunnel(_)
));
}
assert!(matches!( assert!(matches!(
game.board.cells[5][col].get_structure(), game.board.cells[5][2].get_structure(),
Structures::Tunnel(_) Structures::Base(_)
)); ));
} assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
assert!(matches!( assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
game.board.cells[5][2].get_structure(), assert!(game.board.cells[5][2].get_ref_option_unit().is_none());
Structures::Base(_) assert!(game.board.cells[5][3].get_ref_option_unit().is_none());
));
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
assert!(game.board.cells[5][2].get_ref_option_unit().is_none());
assert!(game.board.cells[5][3].get_ref_option_unit().is_none());
} }
#[test] #[test]
fn each_tunnel_step_takes_three_seconds_in_both_directions() { fn each_tunnel_step_takes_three_seconds_in_both_directions() {
for path in [[(5, 3), (5, 4)], [(4, 2), (3, 2)]] { for path in [[(5, 3), (5, 4)], [(4, 2), (3, 2)]] {
let mut game = game(); let mut game = game();
for (row, col) in path { for (row, col) in path {
tunnel(&mut game.board, row, col); tunnel(&mut game.board, row, col);
}
queue(&mut game.board, (5, 2), &path);
advance(&mut game, travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
game.tick_update();
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_coords(),
path[0]
);
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_ref_tasks()[0]
.get_path()
.len(),
1
);
advance(&mut game, travel_ticks() - 1);
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_coords(),
path[0]
);
game.tick_update();
assert!(
unit(&game.board, path[1].0, path[1].1)
.get_ref_tasks()
.is_empty()
);
} }
queue(&mut game.board, (5, 2), &path);
advance(&mut game, travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
game.tick_update();
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_coords(),
path[0]
);
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_ref_tasks()[0]
.get_path()
.len(),
1
);
advance(&mut game, travel_ticks() - 1);
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_coords(),
path[0]
);
game.tick_update();
assert!(
unit(&game.board, path[1].0, path[1].1)
.get_ref_tasks()
.is_empty()
);
}
} }
#[test] #[test]
fn queued_tasks_execute_in_order_and_keep_unit_coordinates_current() { fn queued_tasks_execute_in_order_and_keep_unit_coordinates_current() {
let mut game = game(); let mut game = game();
tunnel(&mut game.board, 5, 3); tunnel(&mut game.board, 5, 3);
tunnel(&mut game.board, 5, 4); tunnel(&mut game.board, 5, 4);
queue(&mut game.board, (5, 2), &[(5, 3)]); queue(&mut game.board, (5, 2), &[(5, 3)]);
queue(&mut game.board, (5, 2), &[(5, 4)]); queue(&mut game.board, (5, 2), &[(5, 4)]);
advance(&mut game, travel_ticks()); advance(&mut game, travel_ticks());
assert_eq!(unit(&game.board, 5, 3).get_ref_tasks().len(), 1); assert_eq!(unit(&game.board, 5, 3).get_ref_tasks().len(), 1);
advance(&mut game, travel_ticks()); advance(&mut game, travel_ticks());
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4)); assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty()); assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
queue(&mut game.board, (5, 4), &[(5, 3), (5, 2)]); queue(&mut game.board, (5, 4), &[(5, 3), (5, 2)]);
advance(&mut game, 2 * travel_ticks() - 1); advance(&mut game, 2 * travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3)); assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
game.tick_update(); game.tick_update();
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2)); assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
assert!(matches!( assert!(matches!(
game.board.cells[5][2].get_structure(), game.board.cells[5][2].get_structure(),
Structures::Base(_) Structures::Base(_)
)); ));
} }
#[test] #[test]
fn occupied_tunnel_waits_without_losing_units_or_task() { fn occupied_tunnel_waits_without_losing_units_or_task() {
let mut game = game(); let mut game = game();
tunnel(&mut game.board, 5, 3); tunnel(&mut game.board, 5, 3);
*game.board.cells[5][3].get_mut_option_unit() = *game.board.cells[5][3].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(Players::Player, (5, 3)))); Some(Units::Miner(MinerUnit::new(Players::Player, (5, 3))));
queue(&mut game.board, (5, 2), &[(5, 3)]); queue(&mut game.board, (5, 2), &[(5, 3)]);
for _ in 0..3 { for _ in 0..3 {
game.tick_update();
}
assert_eq!(unit(&game.board, 5, 2).get_ref_tasks().len(), 1);
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
game.board.cells[5][3].get_mut_option_unit().take();
advance(&mut game, travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
game.tick_update(); game.tick_update();
} assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
assert_eq!(unit(&game.board, 5, 2).get_ref_tasks().len(), 1); assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
game.board.cells[5][3].get_mut_option_unit().take();
advance(&mut game, travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
game.tick_update();
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
} }
#[test] #[test]
fn ore_is_not_destroyed_by_digging_task() { fn ore_is_not_destroyed_by_digging_task() {
let mut game = game(); let mut game = game();
let ore = game.board.cells[5][1].get_structure(); let ore = game.board.cells[5][1].get_structure();
queue(&mut game.board, (5, 2), &[(5, 1)]); queue(&mut game.board, (5, 2), &[(5, 1)]);
for _ in 0..200 { for _ in 0..200 {
game.tick_update(); game.tick_update();
} }
assert_eq!(game.board.cells[5][1].get_structure(), ore); assert_eq!(game.board.cells[5][1].get_structure(), ore);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2)); assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
} }
#[test] #[test]
fn invalid_or_empty_routes_do_not_teleport_or_remove_miner() { fn invalid_or_empty_routes_do_not_teleport_or_remove_miner() {
for path in [vec![], vec![(usize::MAX, usize::MAX)], vec![(0, 0)]] { for path in [vec![], vec![(usize::MAX, usize::MAX)], vec![(0, 0)]] {
let mut game = game(); let mut game = game();
queue(&mut game.board, (5, 2), &path); queue(&mut game.board, (5, 2), &path);
game.tick_update(); game.tick_update();
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2)); assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
assert!(unit(&game.board, 5, 2).get_ref_tasks().is_empty()); assert!(unit(&game.board, 5, 2).get_ref_tasks().is_empty());
} }
} }
#[test] #[test]
fn only_player_miner_can_start_drawing_route() { fn only_player_miner_can_start_drawing_route() {
let mut game = game(); let mut game = game();
game.board.toggle_marking(); game.board.toggle_marking();
assert!(!game.board.marked_cells.marking_cells); assert!(!game.board.marked_cells.marking_cells);
for _ in 0..36 { for _ in 0..36 {
game.board.change_focused_cell(MoveFocusedCell::Right); game.board.change_focused_cell(MoveFocusedCell::Right);
} }
assert_eq!(game.board.get_focused_cell().get_coords(), (5, 39)); assert_eq!(game.board.get_focused_cell().get_coords(), (5, 39));
game.board.toggle_marking(); game.board.toggle_marking();
assert!(!game.board.marked_cells.marking_cells); assert!(!game.board.marked_cells.marking_cells);
assert!(game.board.get_marked_cells().is_empty()); assert!(game.board.get_marked_cells().is_empty());
} }
#[test] #[test]
fn drawing_route_pauses_selected_miner_and_boundary_moves_do_not_repeat_cells() { fn drawing_route_pauses_selected_miner_and_boundary_moves_do_not_repeat_cells() {
let mut game = game(); let mut game = game();
game.board.change_focused_cell(MoveFocusedCell::Left); game.board.change_focused_cell(MoveFocusedCell::Left);
queue(&mut game.board, (5, 2), &[(5, 3)]); queue(&mut game.board, (5, 2), &[(5, 3)]);
game.board.toggle_marking(); game.board.toggle_marking();
game.tick_update(); game.tick_update();
assert_eq!( assert_eq!(
game.board.cells[5][3].get_structure().get_durability(), game.board.cells[5][3].get_structure().get_durability(),
1000 1000
); );
for _ in 0..10 { for _ in 0..10 {
game.board.change_focused_cell(MoveFocusedCell::Up); game.board.change_focused_cell(MoveFocusedCell::Up);
} }
let coords: Vec<_> = game.board.get_marked_cells().iter().copied().collect(); let coords: Vec<_> = game.board.get_marked_cells().iter().copied().collect();
assert!(coords.windows(2).all(|pair| pair[0] != pair[1])); assert!(coords.windows(2).all(|pair| pair[0] != pair[1]));
game.board.end_marking_cells(true); game.board.end_marking_cells(true);
game.tick_update(); game.tick_update();
assert!(game.board.cells[5][3].get_structure().get_durability() < 1000); assert!(game.board.cells[5][3].get_structure().get_durability() < 1000);
} }
#[test] #[test]
fn drawing_another_task_extends_the_end_of_queued_work() { fn drawing_another_task_extends_the_end_of_queued_work() {
let mut game = game(); let mut game = game();
for col in [3, 4, 5] { for col in [3, 4, 5] {
tunnel(&mut game.board, 5, col); tunnel(&mut game.board, 5, col);
} }
game.board.change_focused_cell(MoveFocusedCell::Left); game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking(); game.board.toggle_marking();
game.board.change_focused_cell(MoveFocusedCell::Right); game.board.change_focused_cell(MoveFocusedCell::Right);
game.board.change_focused_cell(MoveFocusedCell::Right); game.board.change_focused_cell(MoveFocusedCell::Right);
game.board.toggle_marking(); game.board.toggle_marking();
game.board.change_focused_cell(MoveFocusedCell::Left); game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.change_focused_cell(MoveFocusedCell::Left); game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking(); game.board.toggle_marking();
assert_eq!(game.board.get_focused_cell().get_coords(), (5, 4)); assert_eq!(game.board.get_focused_cell().get_coords(), (5, 4));
game.board.change_focused_cell(MoveFocusedCell::Right); game.board.change_focused_cell(MoveFocusedCell::Right);
game.board.toggle_marking(); game.board.toggle_marking();
assert_eq!(unit(&game.board, 5, 2).get_ref_tasks().len(), 2); assert_eq!(unit(&game.board, 5, 2).get_ref_tasks().len(), 2);
advance(&mut game, 3 * travel_ticks()); advance(&mut game, 3 * travel_ticks());
assert_eq!(unit(&game.board, 5, 5).get_coords(), (5, 5)); assert_eq!(unit(&game.board, 5, 5).get_coords(), (5, 5));
assert!(unit(&game.board, 5, 5).get_ref_tasks().is_empty()); assert!(unit(&game.board, 5, 5).get_ref_tasks().is_empty());
} }
#[test] #[test]
fn freshly_dug_cell_is_entered_immediately_then_tunnel_travel_takes_three_seconds() { fn freshly_dug_cell_is_entered_immediately_then_tunnel_travel_takes_three_seconds() {
let mut game = game(); let mut game = game();
tunnel(&mut game.board, 5, 4); tunnel(&mut game.board, 5, 4);
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]); queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
for _ in 0..200 { for _ in 0..200 {
game.tick_update(); game.tick_update();
if matches!( if matches!(
game.board.cells[5][3].get_structure(), game.board.cells[5][3].get_structure(),
Structures::Tunnel(_) Structures::Tunnel(_)
) { ) {
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3)); assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
advance(&mut game, travel_ticks() - 1); advance(&mut game, travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3)); assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
game.tick_update(); game.tick_update();
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty()); assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
return; return;
}
} }
} panic!("miner did not finish digging the stone");
panic!("miner did not finish digging the stone");
} }
#[test] #[test]
fn miner_symbol_in_tunnel_uses_owner_color_even_when_selected_or_marked() { fn miner_symbol_in_tunnel_uses_owner_color_even_when_selected_or_marked() {
for owner in [Players::Player, Players::Enemy] { for owner in [Players::Player, Players::Enemy] {
for zoom in [ZoomLevel::Default, ZoomLevel::ZoomedIn] { for zoom in [ZoomLevel::Default, ZoomLevel::ZoomedIn] {
for (selected, marked) in [(false, false), (true, false), (false, true), (true, true)] { for (selected, marked) in [(false, false), (true, false), (false, true), (true, true)] {
let mut game = game(); let mut game = game();
let cell = &mut game.board.cells[5][3]; let cell = &mut game.board.cells[5][3];
cell.set_structure(Structures::Tunnel(Tunnel::new())); cell.set_structure(Structures::Tunnel(Tunnel::new()));
cell cell.set_selected(selected)
.set_selected(selected) .set_marked(marked)
.set_marked(marked) .set_zoom_level(zoom);
.set_zoom_level(zoom); *cell.get_mut_option_unit() = Some(Units::Miner(MinerUnit::new(owner, (5, 3))));
*cell.get_mut_option_unit() = Some(Units::Miner(MinerUnit::new(owner, (5, 3)))); let area = Rect::new(
let area = Rect::new( 0,
0, 0,
0, zoom.get_cell_size(CellSizes::Width) as u16,
zoom.get_cell_size(CellSizes::Width) as u16, zoom.get_cell_size(CellSizes::Height) as u16,
zoom.get_cell_size(CellSizes::Height) as u16, );
); let mut buffer = Buffer::empty(area);
let mut buffer = Buffer::empty(area); cell.clone().render(area, &mut buffer);
cell.clone().render(area, &mut buffer); let marker = buffer
let marker = buffer .content
.content .iter()
.iter() .find(|cell| cell.symbol() == "M")
.find(|cell| cell.symbol() == "M") .unwrap();
.unwrap(); assert_eq!(marker.fg, owner.get_color(), "{owner:?} {zoom:?}");
assert_eq!(marker.fg, owner.get_color(), "{owner:?} {zoom:?}"); assert!(buffer.content.iter().any(|cell| cell.symbol() == "T"));
assert!(buffer.content.iter().any(|cell| cell.symbol() == "T")); }
} }
} }
}
} }
#[test] #[test]
fn tui_shows_movement_percentage_and_clears_it_after_each_step() { fn tui_shows_movement_percentage_and_clears_it_after_each_step() {
for zoom in [ for zoom in [
ZoomLevel::ZoomedIn, ZoomLevel::ZoomedIn,
ZoomLevel::Default, ZoomLevel::Default,
ZoomLevel::ZoomedOut, ZoomLevel::ZoomedOut,
] { ] {
let mut game = game(); let mut game = game();
for col in [2, 3, 4] { for col in [2, 3, 4] {
tunnel(&mut game.board, 5, col); tunnel(&mut game.board, 5, col);
}
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
let render = |board: &BoardState, col: usize| {
let area = Rect::new(
0,
0,
zoom.get_cell_size(CellSizes::Width) as u16,
zoom.get_cell_size(CellSizes::Height) as u16,
);
let mut buffer = Buffer::empty(area);
let mut cell = board.get_ref_cell(5, col).clone();
cell.set_zoom_level(zoom);
cell.render(area, &mut buffer);
buffer
};
let text = |buffer: &Buffer| -> String {
buffer.content.iter().map(|cell| cell.symbol()).collect()
};
assert!(!text(&render(&game.board, 2)).contains('%'));
advance(&mut game, 46);
let buffer = render(&game.board, 2);
assert!(text(&buffer).contains("50%"), "{zoom:?}");
if zoom != ZoomLevel::ZoomedOut {
let marker = buffer
.content
.iter()
.find(|cell| cell.symbol() == "M")
.unwrap();
assert_eq!(marker.fg, Players::Player.get_color());
}
advance(&mut game, travel_ticks() - 46);
assert!(!text(&render(&game.board, 2)).contains('%'));
assert!(!text(&render(&game.board, 3)).contains('%'));
game.tick_update();
assert!(text(&render(&game.board, 3)).contains("1%"));
advance(&mut game, travel_ticks() - 1);
assert!(!text(&render(&game.board, 4)).contains('%'));
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
} }
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
let render = |board: &BoardState, col: usize| {
let area = Rect::new(
0,
0,
zoom.get_cell_size(CellSizes::Width) as u16,
zoom.get_cell_size(CellSizes::Height) as u16,
);
let mut buffer = Buffer::empty(area);
let mut cell = board.get_ref_cell(5, col).clone();
cell.set_zoom_level(zoom);
cell.render(area, &mut buffer);
buffer
};
let text =
|buffer: &Buffer| -> String { buffer.content.iter().map(|cell| cell.symbol()).collect() };
assert!(!text(&render(&game.board, 2)).contains('%'));
advance(&mut game, 46);
let buffer = render(&game.board, 2);
assert!(text(&buffer).contains("50%"), "{zoom:?}");
if zoom != ZoomLevel::ZoomedOut {
let marker = buffer
.content
.iter()
.find(|cell| cell.symbol() == "M")
.unwrap();
assert_eq!(marker.fg, Players::Player.get_color());
}
advance(&mut game, travel_ticks() - 46);
assert!(!text(&render(&game.board, 2)).contains('%'));
assert!(!text(&render(&game.board, 3)).contains('%'));
game.tick_update();
assert!(text(&render(&game.board, 3)).contains("1%"));
advance(&mut game, travel_ticks() - 1);
assert!(!text(&render(&game.board, 4)).contains('%'));
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
}
} }
#[test] #[test]
fn confirmed_route_splits_into_moving_and_digging_segments() { fn confirmed_route_splits_into_moving_and_digging_segments() {
let mut game = game(); let mut game = game();
for col in [3, 4, 7] { for col in [3, 4, 7] {
tunnel(&mut game.board, 5, col); tunnel(&mut game.board, 5, col);
} }
game.board.change_focused_cell(MoveFocusedCell::Left); game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking(); game.board.toggle_marking();
for _ in 0..5 { for _ in 0..5 {
game.board.change_focused_cell(MoveFocusedCell::Right); game.board.change_focused_cell(MoveFocusedCell::Right);
} }
game.board.toggle_marking(); game.board.toggle_marking();
let tasks = unit(&game.board, 5, 2).get_ref_tasks(); let tasks = unit(&game.board, 5, 2).get_ref_tasks();
assert_eq!(tasks.len(), 3); assert_eq!(tasks.len(), 3);
assert!(matches!(tasks[0], Tasks::Moving(_))); assert!(matches!(tasks[0], Tasks::Moving(_)));
assert_eq!( assert_eq!(
tasks[0].get_path().iter().copied().collect::<Vec<_>>(), tasks[0].get_path().iter().copied().collect::<Vec<_>>(),
[(5, 3), (5, 4)] [(5, 3), (5, 4)]
); );
assert!(matches!(tasks[1], Tasks::Digging(_))); assert!(matches!(tasks[1], Tasks::Digging(_)));
assert_eq!( assert_eq!(
tasks[1].get_path().iter().copied().collect::<Vec<_>>(), tasks[1].get_path().iter().copied().collect::<Vec<_>>(),
[(5, 5), (5, 6)] [(5, 5), (5, 6)]
); );
assert!(matches!(tasks[2], Tasks::Moving(_))); assert!(matches!(tasks[2], Tasks::Moving(_)));
assert_eq!(tasks[0].get_icon(), '→'); assert_eq!(tasks[0].get_icon(), '→');
assert_eq!(tasks[1].get_icon(), '⛏'); assert_eq!(tasks[1].get_icon(), '⛏');
advance(&mut game, 2 * travel_ticks()); advance(&mut game, 2 * travel_ticks());
assert!(matches!( assert!(matches!(
unit(&game.board, 5, 4).get_ref_tasks()[0], unit(&game.board, 5, 4).get_ref_tasks()[0],
Tasks::Digging(_) Tasks::Digging(_)
)); ));
advance(&mut game, 2 * 182); advance(&mut game, 2 * 182);
assert!(matches!( assert!(matches!(
unit(&game.board, 5, 6).get_ref_tasks()[0], unit(&game.board, 5, 6).get_ref_tasks()[0],
Tasks::Moving(_) Tasks::Moving(_)
)); ));
advance(&mut game, travel_ticks()); advance(&mut game, travel_ticks());
assert!(unit(&game.board, 5, 7).get_ref_tasks().is_empty()); assert!(unit(&game.board, 5, 7).get_ref_tasks().is_empty());
} }
#[test] #[test]
fn moving_task_does_not_damage_walkable_structure() { fn moving_task_does_not_damage_walkable_structure() {
let mut game = game(); let mut game = game();
tunnel(&mut game.board, 5, 3); tunnel(&mut game.board, 5, 3);
let structure = game.board.cells[5][3].get_structure(); let structure = game.board.cells[5][3].get_structure();
game.board.cells[5][2] game.board.cells[5][2]
.get_mut_option_unit() .get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into()))); .set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
advance(&mut game, travel_ticks()); advance(&mut game, travel_ticks());
assert_eq!(game.board.cells[5][3].get_structure(), structure); assert_eq!(game.board.cells[5][3].get_structure(), structure);
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty()); assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
} }
#[test] #[test]
fn previously_dug_route_becomes_moving_without_instant_steps() { fn previously_dug_route_becomes_moving_without_instant_steps() {
let mut game = game(); let mut game = game();
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4), (5, 3)]); queue(&mut game.board, (5, 2), &[(5, 3), (5, 4), (5, 3)]);
advance(&mut game, 2 * 182); advance(&mut game, 2 * 182);
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4)); assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
game.tick_update(); game.tick_update();
assert!(matches!( assert!(matches!(
unit(&game.board, 5, 4).get_ref_tasks()[0], unit(&game.board, 5, 4).get_ref_tasks()[0],
Tasks::Moving(_) Tasks::Moving(_)
)); ));
advance(&mut game, travel_ticks() - 2); advance(&mut game, travel_ticks() - 2);
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4)); assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
game.tick_update(); game.tick_update();
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty()); assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
} }
#[test] #[test]
fn route_reclassifies_as_digging_if_target_is_stone() { fn route_reclassifies_as_digging_if_target_is_stone() {
let mut game = game(); let mut game = game();
game.board.cells[5][2] game.board.cells[5][2]
.get_mut_option_unit() .get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into()))); .set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
game.tick_update(); game.tick_update();
assert!(matches!( assert!(matches!(
unit(&game.board, 5, 2).get_ref_tasks()[0], unit(&game.board, 5, 2).get_ref_tasks()[0],
Tasks::Digging(_) Tasks::Digging(_)
)); ));
assert!(game.board.cells[5][3].get_structure().get_durability() < 1000); assert!(game.board.cells[5][3].get_structure().get_durability() < 1000);
advance(&mut game, 181); advance(&mut game, 181);
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty()); assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
} }
#[test] #[test]
fn tab_panel_shows_current_step_and_total_for_active_and_pending_tasks() -> () { fn tab_panel_shows_current_step_and_total_for_active_and_pending_tasks() -> () {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio", "--view", "skirmish"]).unwrap(); let args: Cli = Cli::try_parse_from(["game", "--disable-audio", "--view", "skirmish"]).unwrap();
let channels: (Sender<AudioCmd>, Receiver<AudioCmd>) = channel(); let channels: (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, channels.0); let mut app: App = App::new(args, channels.0);
app.window_area = Rect::new(0, 0, 120, 60); app.window_area = Rect::new(0, 0, 120, 60);
app.states = Some(GameStates::new(&app.args, &app.window_area)); app.states = Some(GameStates::new(&app.args, &app.window_area));
let state: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; let state: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
tunnel(&mut state.board, 5, 3); tunnel(&mut state.board, 5, 3);
tunnel(&mut state.board, 5, 4); tunnel(&mut state.board, 5, 4);
state.board.cells[5][2] state.board.cells[5][2]
.get_mut_option_unit() .get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3), (5, 4)].into()))); .set_task(Tasks::Moving(MovingTask::new([(5, 3), (5, 4)].into())));
state.board.cells[5][2] state.board.cells[5][2]
.get_mut_option_unit() .get_mut_option_unit()
.set_task(Tasks::Digging(DiggingTask::new([(5, 5)].into(), 0.55))); .set_task(Tasks::Digging(DiggingTask::new([(5, 5)].into(), 0.55)));
advance(state, travel_ticks() + 46); advance(state, travel_ticks() + 46);
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); handle_keybindings(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(120, 60)).unwrap(); let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(120, 60)).unwrap();
terminal terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () { .draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area()); frame.render_widget(&mut app, frame.area());
}) })
.unwrap(); .unwrap();
let text: String = terminal let text: String = terminal
.backend() .backend()
.buffer() .buffer()
.content .content
.iter() .iter()
.map(|cell: &ratatui::buffer::Cell| -> &str { cell.symbol() }) .map(|cell: &ratatui::buffer::Cell| -> &str { cell.symbol() })
.collect(); .collect();
assert!(text.contains("Step: 50% | Total: 75%")); assert!(text.contains("Step: 50% | Total: 75%"));
assert!(text.contains("Step: 0% | Total: 0%")); assert!(text.contains("Step: 0% | Total: 0%"));
} }
#[test] #[test]
fn space_route_tick_and_tui_show_remaining_durability_at_every_zoom() { fn space_route_tick_and_tui_show_remaining_durability_at_every_zoom() {
let args = Cli::try_parse_from(["game", "--disable-audio", "--view", "skirmish"]).unwrap(); let args = Cli::try_parse_from(["game", "--disable-audio", "--view", "skirmish"]).unwrap();
let (tx, _) = channel(); let (tx, _) = channel();
let mut app = App::new(args, tx); let mut app = App::new(args, tx);
app.window_area = Rect::new(0, 0, 120, 40); app.window_area = Rect::new(0, 0, 120, 40);
app.states = Some(GameStates::new(&app.args, &app.window_area)); app.states = Some(GameStates::new(&app.args, &app.window_area));
for code in [ for code in [
KeyCode::Left, KeyCode::Left,
KeyCode::Char(' '), KeyCode::Char(' '),
KeyCode::Right, KeyCode::Right,
KeyCode::Char(' '), KeyCode::Char(' '),
] { ] {
handle_keybindings(&mut app, KeyEvent::new(code, KeyModifiers::NONE)); handle_keybindings(&mut app, KeyEvent::new(code, KeyModifiers::NONE));
} }
app.states_mut().unwrap().skirmish.tick_update(); app.states_mut().unwrap().skirmish.tick_update();
let durability = app.states_ref().unwrap().skirmish.board.cells[5][3] let durability = app.states_ref().unwrap().skirmish.board.cells[5][3]
.get_structure() .get_structure()
.get_durability(); .get_durability();
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap(); let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
for zoom in [ for zoom in [
ZoomLevel::ZoomedIn, ZoomLevel::ZoomedIn,
ZoomLevel::Default, ZoomLevel::Default,
ZoomLevel::ZoomedOut, ZoomLevel::ZoomedOut,
] { ] {
let state = &mut app.states_mut().unwrap().skirmish; let state = &mut app.states_mut().unwrap().skirmish;
state.board.change_zoom(zoom); state.board.change_zoom(zoom);
state.board.vertical_offset.set_value(5); state.board.vertical_offset.set_value(5);
terminal
.draw(|frame| frame.render_widget(&mut app, frame.area()))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("99%"), "{zoom:?}");
}
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
terminal terminal
.draw(|frame| frame.render_widget(&mut app, frame.area())) .draw(|frame| frame.render_widget(&mut app, frame.area()))
.unwrap(); .unwrap();
let text: String = terminal let text: String = terminal
.backend() .backend()
.buffer() .buffer()
.content .content
.iter() .iter()
.map(|cell| cell.symbol()) .map(|cell| cell.symbol())
.collect(); .collect();
assert!(text.contains("99%"), "{zoom:?}"); assert!(text.contains(&format!("{durability}/1000")));
} assert_eq!(app.view, View::Skirmish);
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
terminal
.draw(|frame| frame.render_widget(&mut app, frame.area()))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains(&format!("{durability}/1000")));
assert_eq!(app.view, View::Skirmish);
} }
+279 -280
View File
@@ -1,338 +1,337 @@
use clap::Parser; use clap::Parser;
use ratatui::{ use ratatui::{
Terminal, Terminal,
backend::TestBackend, backend::TestBackend,
buffer::Cell, buffer::Cell,
crossterm::event::{KeyCode, KeyEvent, KeyModifiers}, crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect, layout::Rect,
}; };
use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{ use war_in_tunnels::{
app::{ app::{
Action, App, GameStates, View, available_skirmish_actions, handle_keybindings, Action, App, GameStates, View, available_skirmish_actions, handle_keybindings,
states::{ states::{
SkirmishState, SkirmishState,
skirmish_states::{ skirmish_states::{
MoveFocusedCell, Players, Resources, MoveFocusedCell, Players, Resources,
structures::{BaseBuilding, Structures, Tunnel}, structures::{BaseBuilding, Structures, Tunnel},
units::{MinerUnit, Unit, Units}, units::{MinerUnit, Unit, Units},
}, },
},
threads::{AudioCmd, TICK_MS},
}, },
threads::{AudioCmd, TICK_MS}, cli::Cli,
},
cli::Cli,
}; };
fn app() -> App { fn app() -> App {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap(); let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel(); let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, tx); let mut app: App = App::new(args, tx);
app.view = View::Skirmish; app.view = View::Skirmish;
app.window_area = Rect::new(0, 0, 160, 50); app.window_area = Rect::new(0, 0, 160, 50);
app.states = Some(GameStates::new(&app.args, &app.window_area)); app.states = Some(GameStates::new(&app.args, &app.window_area));
app app
} }
fn base(game: &SkirmishState, coords: (usize, usize)) -> BaseBuilding { fn base(game: &SkirmishState, coords: (usize, usize)) -> BaseBuilding {
let Structures::Base(base) = game.board.cells[coords.0][coords.1].get_structure() else { let Structures::Base(base) = game.board.cells[coords.0][coords.1].get_structure() else {
panic!("base missing"); panic!("base missing");
}; };
base base
} }
fn press(app: &mut App, code: KeyCode, modifiers: KeyModifiers) -> () { fn press(app: &mut App, code: KeyCode, modifiers: KeyModifiers) -> () {
handle_keybindings(app, KeyEvent::new(code, modifiers)); handle_keybindings(app, KeyEvent::new(code, modifiers));
} }
#[test] #[test]
fn recruitment_is_owned_by_base_and_invalid_requests_do_not_charge() -> () { fn recruitment_is_owned_by_base_and_invalid_requests_do_not_charge() -> () {
let mut app: App = app(); let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
let before: Resources = game.player_resources; let before: Resources = game.player_resources;
for coords in [(5, 39), (0, 0), (usize::MAX, 2)] { for coords in [(5, 39), (0, 0), (usize::MAX, 2)] {
assert!(!game.recruit_miner(coords, Players::Player)); assert!(!game.recruit_miner(coords, Players::Player));
assert_eq!(game.player_resources, before); assert_eq!(game.player_resources, before);
} }
assert!(!game.recruit_miner((5, 2), Players::Unclaimed)); assert!(!game.recruit_miner((5, 2), Players::Unclaimed));
assert!(game.recruit_miner((5, 2), Players::Player)); assert!(game.recruit_miner((5, 2), Players::Player));
assert_eq!(game.player_resources, Resources::new(40, 10)); assert_eq!(game.player_resources, Resources::new(40, 10));
assert_eq!(game.enemy_resources, Resources::new(50, 25)); assert_eq!(game.enemy_resources, Resources::new(50, 25));
assert_eq!(base(game, (5, 2)).queued_miners(), 1); assert_eq!(base(game, (5, 2)).queued_miners(), 1);
} }
#[test] #[test]
fn recruitment_requires_both_resources_and_charges_each_queue_entry() -> () { fn recruitment_requires_both_resources_and_charges_each_queue_entry() -> () {
let mut app: App = app(); let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
for resources in [ for resources in [
Resources { wood: 9, metal: 15 }, Resources { wood: 9, metal: 15 },
Resources { Resources {
wood: 10, wood: 10,
metal: 14, metal: 14,
}, },
] { ] {
game.player_resources = resources; game.player_resources = resources;
assert!(!game.can_recruit_miner((5, 2), Players::Player)); assert!(!game.can_recruit_miner((5, 2), Players::Player));
assert!(!game.recruit_miner((5, 2), Players::Player));
assert_eq!(game.player_resources, resources);
assert_eq!(base(game, (5, 2)).queued_miners(), 0);
}
game.player_resources = Resources::new(20, 30);
assert!(game.recruit_miner((5, 2), Players::Player));
assert!(game.recruit_miner((5, 2), Players::Player));
assert_eq!(base(game, (5, 2)).queued_miners(), 2);
assert_eq!(game.player_resources, Resources::new(0, 0));
assert!(!game.recruit_miner((5, 2), Players::Player)); assert!(!game.recruit_miner((5, 2), Players::Player));
assert_eq!(game.player_resources, resources);
assert_eq!(base(game, (5, 2)).queued_miners(), 0);
}
game.player_resources = Resources::new(20, 30);
assert!(game.recruit_miner((5, 2), Players::Player));
assert!(game.recruit_miner((5, 2), Players::Player));
assert_eq!(base(game, (5, 2)).queued_miners(), 2);
assert_eq!(game.player_resources, Resources::new(0, 0));
assert!(!game.recruit_miner((5, 2), Players::Player));
} }
#[test] #[test]
fn first_recruit_waits_at_full_progress_and_following_recruits_pause_until_departure() -> () { fn first_recruit_waits_at_full_progress_and_following_recruits_pause_until_departure() -> () {
let mut app: App = app(); let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
game.player_resources = Resources::new(100, 100); game.player_resources = Resources::new(100, 100);
assert!(game.recruit_miner((5, 2), Players::Player)); assert!(game.recruit_miner((5, 2), Players::Player));
assert!(game.recruit_miner((5, 2), Players::Player)); assert!(game.recruit_miner((5, 2), Players::Player));
let original: Option<Units> = game.board.cells[5][2].get_option_unit(); let original: Option<Units> = game.board.cells[5][2].get_option_unit();
for _ in 0..2 * BaseBuilding::RECRUITMENT_TICKS { for _ in 0..2 * BaseBuilding::RECRUITMENT_TICKS {
game.tick_update();
}
assert_eq!(base(game, (5, 2)).queued_miners(), 2);
assert_eq!(base(game, (5, 2)).recruitment_progress(), 100);
assert_eq!(game.board.cells[5][2].get_option_unit(), original);
game.board.cells[5][2].get_mut_option_unit().take();
game.tick_update(); game.tick_update();
} let spawned: Option<Units> = game.board.cells[5][2].get_option_unit();
assert_eq!(base(game, (5, 2)).queued_miners(), 2); assert_eq!(spawned.get_owner(), Players::Player);
assert_eq!(base(game, (5, 2)).recruitment_progress(), 100); assert_eq!(spawned.get_coords(), (5, 2));
assert_eq!(game.board.cells[5][2].get_option_unit(), original); assert!(spawned.get_ref_tasks().is_empty());
game.board.cells[5][2].get_mut_option_unit().take(); assert_eq!(base(game, (5, 2)).queued_miners(), 1);
game.tick_update(); for _ in 0..2 * BaseBuilding::RECRUITMENT_TICKS {
let spawned: Option<Units> = game.board.cells[5][2].get_option_unit(); game.tick_update();
assert_eq!(spawned.get_owner(), Players::Player); }
assert_eq!(spawned.get_coords(), (5, 2)); assert_eq!(base(game, (5, 2)).queued_miners(), 1);
assert!(spawned.get_ref_tasks().is_empty()); assert_eq!(base(game, (5, 2)).recruitment_progress(), 0);
assert_eq!(base(game, (5, 2)).queued_miners(), 1); assert_eq!(game.board.cells[5][2].get_option_unit(), spawned);
for _ in 0..2 * BaseBuilding::RECRUITMENT_TICKS { game.board.cells[5][2].get_mut_option_unit().take();
for _ in 1..BaseBuilding::RECRUITMENT_TICKS {
game.tick_update();
}
assert!(game.board.cells[5][2].get_ref_option_unit().is_none());
assert_eq!(base(game, (5, 2)).recruitment_progress(), 99);
game.tick_update(); game.tick_update();
} assert_eq!(base(game, (5, 2)).queued_miners(), 0);
assert_eq!(base(game, (5, 2)).queued_miners(), 1); assert_eq!(
assert_eq!(base(game, (5, 2)).recruitment_progress(), 0); game.board.cells[5][2].get_ref_option_unit().get_owner(),
assert_eq!(game.board.cells[5][2].get_option_unit(), spawned); Players::Player
game.board.cells[5][2].get_mut_option_unit().take(); );
for _ in 1..BaseBuilding::RECRUITMENT_TICKS {
game.tick_update();
}
assert!(game.board.cells[5][2].get_ref_option_unit().is_none());
assert_eq!(base(game, (5, 2)).recruitment_progress(), 99);
game.tick_update();
assert_eq!(base(game, (5, 2)).queued_miners(), 0);
assert_eq!(
game.board.cells[5][2].get_ref_option_unit().get_owner(),
Players::Player
);
} }
#[test] #[test]
fn walking_out_of_base_resumes_recruitment_and_new_miner_can_receive_tasks() -> () { fn walking_out_of_base_resumes_recruitment_and_new_miner_can_receive_tasks() -> () {
let mut app: App = app(); let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
assert!(game.recruit_miner((5, 2), Players::Player)); assert!(game.recruit_miner((5, 2), Players::Player));
game.board.cells[5][3].set_structure(Structures::Tunnel(Tunnel::new())); game.board.cells[5][3].set_structure(Structures::Tunnel(Tunnel::new()));
game.board.change_focused_cell(MoveFocusedCell::Left); game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking(); game.board.toggle_marking();
game.board.change_focused_cell(MoveFocusedCell::Right); game.board.change_focused_cell(MoveFocusedCell::Right);
game.board.toggle_marking(); game.board.toggle_marking();
for _ in 0..3000_u16.div_ceil(u16::from(TICK_MS)) { for _ in 0..3000_u16.div_ceil(u16::from(TICK_MS)) {
game.tick_update();
}
assert!(game.board.cells[5][2].get_ref_option_unit().is_none());
for _ in 3000_u16.div_ceil(u16::from(TICK_MS))..BaseBuilding::RECRUITMENT_TICKS - 1 {
game.tick_update();
}
assert!(game.board.cells[5][2].get_ref_option_unit().is_none());
assert_eq!(base(game, (5, 2)).recruitment_progress(), 99);
game.tick_update(); game.tick_update();
} assert_eq!(base(game, (5, 2)).queued_miners(), 0);
assert!(game.board.cells[5][2].get_ref_option_unit().is_none()); assert_eq!(
for _ in 3000_u16.div_ceil(u16::from(TICK_MS))..BaseBuilding::RECRUITMENT_TICKS - 1 { game.board.cells[5][2].get_ref_option_unit().get_coords(),
game.tick_update(); (5, 2)
} );
assert!(game.board.cells[5][2].get_ref_option_unit().is_none()); assert_eq!(
assert_eq!(base(game, (5, 2)).recruitment_progress(), 99); game.board.cells[5][3].get_ref_option_unit().get_coords(),
game.tick_update(); (5, 3)
assert_eq!(base(game, (5, 2)).queued_miners(), 0); );
assert_eq!( game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.cells[5][2].get_ref_option_unit().get_coords(), game.board.toggle_marking();
(5, 2) game.board.change_focused_cell(MoveFocusedCell::Down);
); game.board.toggle_marking();
assert_eq!( assert_eq!(
game.board.cells[5][3].get_ref_option_unit().get_coords(), game.board.cells[5][2]
(5, 3) .get_ref_option_unit()
); .get_ref_tasks()
game.board.change_focused_cell(MoveFocusedCell::Left); .len(),
game.board.toggle_marking(); 1
game.board.change_focused_cell(MoveFocusedCell::Down); );
game.board.toggle_marking();
assert_eq!(
game.board.cells[5][2]
.get_ref_option_unit()
.get_ref_tasks()
.len(),
1
);
} }
#[test] #[test]
fn arrival_during_training_does_not_overwrite_visitor_and_ready_recruit_spawns_after_departure() fn arrival_during_training_does_not_overwrite_visitor_and_ready_recruit_spawns_after_departure()
-> () { -> () {
let mut app: App = app(); let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
game.board.cells[5][2].get_mut_option_unit().take(); game.board.cells[5][2].get_mut_option_unit().take();
assert!(game.recruit_miner((5, 2), Players::Player)); assert!(game.recruit_miner((5, 2), Players::Player));
for _ in 0..100 { for _ in 0..100 {
game.tick_update();
}
let visitor: Units = Units::Miner(MinerUnit::new(Players::Enemy, (5, 2)));
*game.board.cells[5][2].get_mut_option_unit() = Some(visitor.clone());
for _ in 0..BaseBuilding::RECRUITMENT_TICKS {
game.tick_update();
}
assert_eq!(base(game, (5, 2)).recruitment_progress(), 100);
assert_eq!(game.board.cells[5][2].get_option_unit(), Some(visitor));
game.board.cells[5][2].get_mut_option_unit().take();
game.tick_update(); game.tick_update();
} assert_eq!(base(game, (5, 2)).queued_miners(), 0);
let visitor: Units = Units::Miner(MinerUnit::new(Players::Enemy, (5, 2))); assert_eq!(
*game.board.cells[5][2].get_mut_option_unit() = Some(visitor.clone()); game.board.cells[5][2].get_ref_option_unit().get_owner(),
for _ in 0..BaseBuilding::RECRUITMENT_TICKS { Players::Player
game.tick_update(); );
}
assert_eq!(base(game, (5, 2)).recruitment_progress(), 100);
assert_eq!(game.board.cells[5][2].get_option_unit(), Some(visitor));
game.board.cells[5][2].get_mut_option_unit().take();
game.tick_update();
assert_eq!(base(game, (5, 2)).queued_miners(), 0);
assert_eq!(
game.board.cells[5][2].get_ref_option_unit().get_owner(),
Players::Player
);
} }
#[test] #[test]
fn bases_have_independent_queues_costs_and_spawn_ownership() -> () { fn bases_have_independent_queues_costs_and_spawn_ownership() -> () {
let mut app: App = app(); let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish; let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
for (coords, owner) in [((5, 2), Players::Player), ((5, 39), Players::Enemy)] { for (coords, owner) in [((5, 2), Players::Player), ((5, 39), Players::Enemy)] {
game.board.cells[coords.0][coords.1] game.board.cells[coords.0][coords.1]
.get_mut_option_unit() .get_mut_option_unit()
.take(); .take();
assert!(game.recruit_miner(coords, owner)); assert!(game.recruit_miner(coords, owner));
} }
assert_eq!(game.player_resources.metal, 10); assert_eq!(game.player_resources.metal, 10);
assert_eq!(game.enemy_resources.metal, 10); assert_eq!(game.enemy_resources.metal, 10);
for _ in 0..BaseBuilding::RECRUITMENT_TICKS { for _ in 0..BaseBuilding::RECRUITMENT_TICKS {
game.tick_update(); game.tick_update();
} }
for (coords, owner) in [((5, 2), Players::Player), ((5, 39), Players::Enemy)] { for (coords, owner) in [((5, 2), Players::Player), ((5, 39), Players::Enemy)] {
assert_eq!(base(game, coords).queued_miners(), 0); assert_eq!(base(game, coords).queued_miners(), 0);
assert_eq!( assert_eq!(
game.board.cells[coords.0][coords.1] game.board.cells[coords.0][coords.1]
.get_ref_option_unit() .get_ref_option_unit()
.get_owner(), .get_owner(),
owner owner
); );
assert_eq!( assert_eq!(
game.board.cells[coords.0][coords.1] game.board.cells[coords.0][coords.1]
.get_ref_option_unit() .get_ref_option_unit()
.get_coords(), .get_coords(),
coords coords
); );
} }
} }
#[test] #[test]
fn recruitment_panel_queues_with_enter_shows_ready_state_and_closes_without_losing_queue() -> () { fn recruitment_panel_queues_with_enter_shows_ready_state_and_closes_without_losing_queue() -> () {
let mut app: App = app(); let mut app: App = app();
press(&mut app, KeyCode::Left, KeyModifiers::NONE); press(&mut app, KeyCode::Left, KeyModifiers::NONE);
assert!( assert!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Recruitment) available_skirmish_actions(&app.states_ref().unwrap().skirmish)
); .contains(&Action::Recruitment)
press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT); );
assert!(app.states_ref().unwrap().skirmish.recruitment_view); press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT);
press(&mut app, KeyCode::Enter, KeyModifiers::NONE); assert!(app.states_ref().unwrap().skirmish.recruitment_view);
press(&mut app, KeyCode::Enter, KeyModifiers::NONE); press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
assert_eq!( press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
base(&app.states_ref().unwrap().skirmish, (5, 2)).queued_miners(), assert_eq!(
1 base(&app.states_ref().unwrap().skirmish, (5, 2)).queued_miners(),
); 1
assert!( );
!available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Enter) assert!(
); !available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Enter)
press(&mut app, KeyCode::Right, KeyModifiers::NONE); );
press(&mut app, KeyCode::Char(' '), KeyModifiers::CONTROL); press(&mut app, KeyCode::Right, KeyModifiers::NONE);
assert_eq!( press(&mut app, KeyCode::Char(' '), KeyModifiers::CONTROL);
app assert_eq!(
.states_ref() app.states_ref()
.unwrap() .unwrap()
.skirmish .skirmish
.board .board
.get_focused_cell() .get_focused_cell()
.get_coords(), .get_coords(),
(5, 2) (5, 2)
); );
assert!(!app.states_ref().unwrap().skirmish.building_view); assert!(!app.states_ref().unwrap().skirmish.building_view);
for _ in 0..BaseBuilding::RECRUITMENT_TICKS { for _ in 0..BaseBuilding::RECRUITMENT_TICKS {
app.states_mut().unwrap().skirmish.tick_update(); app.states_mut().unwrap().skirmish.tick_update();
} }
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 50)).unwrap(); let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 50)).unwrap();
terminal terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () { .draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area()); frame.render_widget(&mut app, frame.area());
}) })
.unwrap(); .unwrap();
let text: String = terminal let text: String = terminal
.backend() .backend()
.buffer() .buffer()
.content .content
.iter() .iter()
.map(|cell: &Cell| -> &str { cell.symbol() }) .map(|cell: &Cell| -> &str { cell.symbol() })
.collect(); .collect();
assert!(text.contains("Cost: 10 Wood + 15 Metal")); assert!(text.contains("Cost: 10 Wood + 15 Metal"));
assert!(text.contains("Queued Miners: 1")); assert!(text.contains("Queued Miners: 1"));
assert!(text.contains("Current progress: 100%")); assert!(text.contains("Current progress: 100%"));
assert!(text.contains("Ready: waiting for free base cell")); assert!(text.contains("Ready: waiting for free base cell"));
assert!(text.contains("Close recruitment")); assert!(text.contains("Close recruitment"));
press(&mut app, KeyCode::Esc, KeyModifiers::NONE); press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view); assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
assert_eq!(app.view, View::Skirmish); assert_eq!(app.view, View::Skirmish);
press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT); press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT);
press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT); press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view); assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
assert_eq!( assert_eq!(
base(&app.states_ref().unwrap().skirmish, (5, 2)).queued_miners(), base(&app.states_ref().unwrap().skirmish, (5, 2)).queued_miners(),
1 1
); );
} }
#[test] #[test]
fn recruitment_panel_is_unavailable_off_base_and_during_route_selection() -> () { fn recruitment_panel_is_unavailable_off_base_and_during_route_selection() -> () {
let mut app: App = app(); let mut app: App = app();
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE); press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view); assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT); press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view); assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
press(&mut app, KeyCode::Left, KeyModifiers::NONE); press(&mut app, KeyCode::Left, KeyModifiers::NONE);
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE); press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view); assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT); press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view); assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
assert!( assert!(
app app.states_ref()
.states_ref() .unwrap()
.unwrap() .skirmish
.skirmish .board
.board .marked_cells
.marked_cells .marking_cells
.marking_cells );
); press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
press(&mut app, KeyCode::Esc, KeyModifiers::NONE); press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT);
press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT); press(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL);
press(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL); assert!(app.exit);
assert!(app.exit);
} }
#[test] #[test]
fn recruitment_fallback_opens_queues_and_closes_on_own_base() -> () { fn recruitment_fallback_opens_queues_and_closes_on_own_base() -> () {
let mut app: App = app(); let mut app: App = app();
press(&mut app, KeyCode::Left, KeyModifiers::NONE); press(&mut app, KeyCode::Left, KeyModifiers::NONE);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE); press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(app.states_ref().unwrap().skirmish.recruitment_view); assert!(app.states_ref().unwrap().skirmish.recruitment_view);
press(&mut app, KeyCode::Enter, KeyModifiers::NONE); press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE); press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view); assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
assert_eq!( assert_eq!(
base(&app.states_ref().unwrap().skirmish, (5, 2)).queued_miners(), base(&app.states_ref().unwrap().skirmish, (5, 2)).queued_miners(),
1 1
); );
assert_eq!( assert_eq!(
app.states_ref().unwrap().skirmish.player_resources, app.states_ref().unwrap().skirmish.player_resources,
Resources::new(40, 10) Resources::new(40, 10)
); );
} }
+123 -121
View File
@@ -2,160 +2,162 @@ use clap::Parser;
use ratatui::{Terminal, backend::TestBackend, buffer::Cell, layout::Rect}; use ratatui::{Terminal, backend::TestBackend, buffer::Cell, layout::Rect};
use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{ use war_in_tunnels::{
app::{ app::{
App, GameStates, View, App, GameStates, View,
states::{ states::{
SkirmishState, SkirmishState,
skirmish_states::{ skirmish_states::{
Players, Resources, Players, Resources,
resources::{MINE_PRODUCTION_TICKS, WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_TICKS}, resources::{MINE_PRODUCTION_TICKS, WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_TICKS},
structures::{Mine, Ore, Stone, Structure, Structures}, structures::{Mine, Ore, Stone, Structure, Structures},
}, },
},
threads::AudioCmd,
}, },
threads::AudioCmd, cli::Cli,
},
cli::Cli,
}; };
fn game() -> SkirmishState { fn game() -> SkirmishState {
let args: Cli = let args: Cli =
Cli::try_parse_from(["game", "--starting-wood", "70", "--starting-metal", "40"]).unwrap(); Cli::try_parse_from(["game", "--starting-wood", "70", "--starting-metal", "40"]).unwrap();
GameStates::new(&args, &Rect::new(0, 0, 160, 40)).skirmish GameStates::new(&args, &Rect::new(0, 0, 160, 40)).skirmish
} }
#[test] #[test]
fn resources_start_from_cli_and_legacy_iron_flag_remains_an_alias() -> () { fn resources_start_from_cli_and_legacy_iron_flag_remains_an_alias() -> () {
let game: SkirmishState = game(); let game: SkirmishState = game();
assert_eq!(game.player_resources, Resources::new(70, 40)); assert_eq!(game.player_resources, Resources::new(70, 40));
assert_eq!(game.enemy_resources, Resources::new(70, 40)); assert_eq!(game.enemy_resources, Resources::new(70, 40));
let args: Cli = Cli::try_parse_from(["game", "--starting-iron", "33"]).unwrap(); let args: Cli = Cli::try_parse_from(["game", "--starting-iron", "33"]).unwrap();
assert_eq!(args.starting_metal, 33); assert_eq!(args.starting_metal, 33);
} }
#[test] #[test]
fn passive_wood_arrives_once_per_interval_without_free_metal() -> () { fn passive_wood_arrives_once_per_interval_without_free_metal() -> () {
let mut game: SkirmishState = game(); let mut game: SkirmishState = game();
for _ in 1..WOOD_DELIVERY_TICKS { for _ in 1..WOOD_DELIVERY_TICKS {
game.tick_update();
}
assert_eq!(game.player_resources.wood, 70);
game.tick_update(); game.tick_update();
} assert_eq!(game.player_resources.wood, 70 + WOOD_DELIVERY_AMOUNT);
assert_eq!(game.player_resources.wood, 70); for _ in 0..WOOD_DELIVERY_TICKS {
game.tick_update(); game.tick_update();
assert_eq!(game.player_resources.wood, 70 + WOOD_DELIVERY_AMOUNT); }
for _ in 0..WOOD_DELIVERY_TICKS { assert_eq!(game.player_resources.wood, 70 + 2 * WOOD_DELIVERY_AMOUNT);
game.tick_update(); assert_eq!(game.player_resources.metal, 40);
} assert_eq!(game.enemy_resources, Resources::new(70, 40));
assert_eq!(game.player_resources.wood, 70 + 2 * WOOD_DELIVERY_AMOUNT);
assert_eq!(game.player_resources.metal, 40);
assert_eq!(game.enemy_resources, Resources::new(70, 40));
} }
#[test] #[test]
fn ore_is_neutral_and_no_mines_spawn_on_the_board() -> () { fn ore_is_neutral_and_no_mines_spawn_on_the_board() -> () {
let game: SkirmishState = game(); let game: SkirmishState = game();
let mut deposits: usize = 0; let mut deposits: usize = 0;
for row in &game.board.cells { for row in &game.board.cells {
for cell in row { for cell in row {
let structure: Structures = cell.get_structure(); let structure: Structures = cell.get_structure();
assert!(!matches!(structure, Structures::Mine(_))); assert!(!matches!(structure, Structures::Mine(_)));
if matches!(structure, Structures::Ore(_)) { if matches!(structure, Structures::Ore(_)) {
deposits += 1; deposits += 1;
assert_eq!(structure.get_owner(), Players::Unclaimed); assert_eq!(structure.get_owner(), Players::Unclaimed);
} }
}
} }
} assert_eq!(deposits, 2);
assert_eq!(deposits, 2);
} }
#[test] #[test]
fn mine_can_only_replace_neutral_ore_and_preserves_its_reserve() -> () { fn mine_can_only_replace_neutral_ore_and_preserves_its_reserve() -> () {
let mut ore: Ore = Ore::new(Players::Unclaimed, 1); let mut ore: Ore = Ore::new(Players::Unclaimed, 1);
assert_eq!(ore.extract(4999), 4999); assert_eq!(ore.extract(4999), 4999);
let mut structure: Structures = Structures::Ore(ore); let mut structure: Structures = Structures::Ore(ore);
assert!(structure.build_mine(Players::Player)); assert!(structure.build_mine(Players::Player));
let Structures::Mine(mut mine) = structure else { let Structures::Mine(mut mine) = structure else {
panic!("expected mine"); panic!("expected mine");
}; };
assert_eq!(mine.get_owner(), Players::Player); assert_eq!(mine.get_owner(), Players::Player);
assert_eq!(mine.get_ore_amount(), 1); assert_eq!(mine.get_ore_amount(), 1);
assert_eq!(mine.produce(), 1); assert_eq!(mine.produce(), 1);
assert_eq!(mine.produce(), 0); assert_eq!(mine.produce(), 0);
assert_eq!(mine.get_ore_amount(), 0); assert_eq!(mine.get_ore_amount(), 0);
assert!(!structure.build_mine(Players::Enemy)); assert!(!structure.build_mine(Players::Enemy));
let mut stone: Structures = Structures::Stone(Stone::new()); let mut stone: Structures = Structures::Stone(Stone::new());
assert!(!stone.build_mine(Players::Player)); assert!(!stone.build_mine(Players::Player));
assert_eq!(stone, Structures::Stone(Stone::new())); assert_eq!(stone, Structures::Stone(Stone::new()));
assert!(Mine::new(Players::Unclaimed, ore).is_none()); assert!(Mine::new(Players::Unclaimed, ore).is_none());
assert!(Mine::new(Players::Player, Ore::new(Players::Enemy, 1)).is_none()); assert!(Mine::new(Players::Player, Ore::new(Players::Enemy, 1)).is_none());
} }
#[test] #[test]
fn mining_credits_each_owner_and_stops_when_deposit_is_empty() -> () { fn mining_credits_each_owner_and_stops_when_deposit_is_empty() -> () {
let mut game: SkirmishState = game(); let mut game: SkirmishState = game();
for (col, owner) in [(1, Players::Player), (40, Players::Enemy)] { for (col, owner) in [(1, Players::Player), (40, Players::Enemy)] {
let mut ore: Ore = Ore::new(Players::Unclaimed, 1); let mut ore: Ore = Ore::new(Players::Unclaimed, 1);
ore.extract(4999); ore.extract(4999);
let mut structure: Structures = Structures::Ore(ore); let mut structure: Structures = Structures::Ore(ore);
assert!(structure.build_mine(owner)); assert!(structure.build_mine(owner));
game.board.cells[5][col].set_structure(structure); game.board.cells[5][col].set_structure(structure);
} }
for _ in 1..MINE_PRODUCTION_TICKS { for _ in 1..MINE_PRODUCTION_TICKS {
game.tick_update();
}
assert_eq!(game.player_resources.metal, 40);
game.tick_update(); game.tick_update();
} assert_eq!(game.player_resources.metal, 41);
assert_eq!(game.player_resources.metal, 40); assert_eq!(game.enemy_resources.metal, 41);
game.tick_update(); for _ in 0..MINE_PRODUCTION_TICKS {
assert_eq!(game.player_resources.metal, 41); game.tick_update();
assert_eq!(game.enemy_resources.metal, 41); }
for _ in 0..MINE_PRODUCTION_TICKS { assert_eq!(game.player_resources.metal, 41);
game.tick_update(); assert_eq!(game.enemy_resources.metal, 41);
} let structure: Structures = game.board.cells[5][1].get_structure();
assert_eq!(game.player_resources.metal, 41); assert!(
assert_eq!(game.enemy_resources.metal, 41); structure
let structure: Structures = game.board.cells[5][1].get_structure(); .text()
assert!( .iter()
structure .any(|line: &ratatui::text::Line<'_>| -> bool {
.text() line.to_string().contains("Ore: 0/5000")
.iter() })
.any(|line: &ratatui::text::Line<'_>| -> bool { line.to_string().contains("Ore: 0/5000") }) );
);
} }
#[test] #[test]
fn resource_additions_do_not_overflow() -> () { fn resource_additions_do_not_overflow() -> () {
let mut resources: Resources = Resources { let mut resources: Resources = Resources {
wood: u32::MAX - 1, wood: u32::MAX - 1,
metal: u32::MAX - 1, metal: u32::MAX - 1,
}; };
resources.add_wood(5); resources.add_wood(5);
resources.add_metal(5); resources.add_metal(5);
assert_eq!(resources.wood, u32::MAX); assert_eq!(resources.wood, u32::MAX);
assert_eq!(resources.metal, u32::MAX); assert_eq!(resources.metal, u32::MAX);
} }
#[test] #[test]
fn tui_reads_current_resources_instead_of_starting_settings() -> () { fn tui_reads_current_resources_instead_of_starting_settings() -> () {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap(); let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel(); let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, tx); let mut app: App = App::new(args, tx);
app.view = View::Skirmish; app.view = View::Skirmish;
app.states = Some(GameStates::new(&app.args, &Rect::new(0, 0, 200, 40))); app.states = Some(GameStates::new(&app.args, &Rect::new(0, 0, 200, 40)));
app.states_mut().unwrap().skirmish.player_resources = Resources { app.states_mut().unwrap().skirmish.player_resources = Resources {
wood: 777, wood: 777,
metal: 888, metal: 888,
}; };
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(200, 40)).unwrap(); let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(200, 40)).unwrap();
terminal terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () { .draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area()); frame.render_widget(&mut app, frame.area());
}) })
.unwrap(); .unwrap();
let text: String = terminal let text: String = terminal
.backend() .backend()
.buffer() .buffer()
.content .content
.iter() .iter()
.map(|cell: &Cell| -> &str { cell.symbol() }) .map(|cell: &Cell| -> &str { cell.symbol() })
.collect(); .collect();
assert!(text.contains("Wood: 777 (+5/5s)")); assert!(text.contains("Wood: 777 (+5/5s)"));
assert!(text.contains("Metal: 888")); assert!(text.contains("Metal: 888"));
assert!(!text.contains("Iron:")); assert!(!text.contains("Iron:"));
} }
+91 -91
View File
@@ -1,123 +1,123 @@
use war_in_tunnels::app::states::skirmish_states::{ use war_in_tunnels::app::states::skirmish_states::{
Players, Players,
tasks::{DiggingTask, MovingTask, Task, Tasks}, tasks::{DiggingTask, MovingTask, Task, Tasks},
units::{MinerUnit, Unit}, units::{MinerUnit, Unit},
}; };
#[test] #[test]
fn digging_progress_accumulates_and_stops_at_completion() { fn digging_progress_accumulates_and_stops_at_completion() {
let mut task = DiggingTask::new([(5, 3)].into(), 40.0); let mut task = DiggingTask::new([(5, 3)].into(), 40.0);
assert!(!task.is_finished()); assert!(!task.is_finished());
task.progress();
assert_eq!(task.get_progress(), 40.0);
assert!(!task.is_finished());
for _ in 0..3 {
task.progress(); task.progress();
} assert_eq!(task.get_progress(), 40.0);
assert_eq!(task.get_progress(), 100.0); assert!(!task.is_finished());
assert!(task.is_finished()); for _ in 0..3 {
task.progress();
}
assert_eq!(task.get_progress(), 100.0);
assert!(task.is_finished());
} }
#[test] #[test]
fn advancing_path_resets_progress_and_handles_empty_path() { fn advancing_path_resets_progress_and_handles_empty_path() {
let mut task = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0); let mut task = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
task.progress(); task.progress();
task.pop_front(); task.pop_front();
assert_eq!(task.current_target(), Some((5, 4))); assert_eq!(task.current_target(), Some((5, 4)));
assert_eq!(task.get_progress(), 0.0); assert_eq!(task.get_progress(), 0.0);
task.pop_front(); task.pop_front();
task.pop_front(); task.pop_front();
assert_eq!(task.current_target(), None); assert_eq!(task.current_target(), None);
assert!(task.get_path().is_empty()); assert!(task.get_path().is_empty());
} }
#[test] #[test]
fn miners_keep_tasks_in_submission_order() { fn miners_keep_tasks_in_submission_order() {
let mut miner = MinerUnit::new(Players::Player, (5, 2)); let mut miner = MinerUnit::new(Players::Player, (5, 2));
for target in [(5, 3), (6, 3)] { for target in [(5, 3), (6, 3)] {
miner.set_task(Tasks::Digging(DiggingTask::new([target].into(), 1.0))); miner.set_task(Tasks::Digging(DiggingTask::new([target].into(), 1.0)));
} }
assert_eq!(miner.get_ref_tasks().len(), 2); assert_eq!(miner.get_ref_tasks().len(), 2);
assert_eq!( assert_eq!(
*miner *miner
.get_mut_tasks() .get_mut_tasks()
.pop_front() .pop_front()
.unwrap() .unwrap()
.get_path_front_coords(), .get_path_front_coords(),
(5, 3) (5, 3)
); );
assert_eq!(*miner.get_ref_tasks()[0].get_path_front_coords(), (6, 3)); assert_eq!(*miner.get_ref_tasks()[0].get_path_front_coords(), (6, 3));
} }
#[test] #[test]
fn movement_progress_is_separate_from_digging_and_resets_for_next_cell() { fn movement_progress_is_separate_from_digging_and_resets_for_next_cell() {
let mut task = MovingTask::new([(5, 3), (5, 4)].into()); let mut task = MovingTask::new([(5, 3), (5, 4)].into());
assert_eq!(task.get_movement_progress(), None); assert_eq!(task.get_movement_progress(), None);
assert_eq!(task.get_progress(), 0.0); assert_eq!(task.get_progress(), 0.0);
assert!(!task.is_finished()); assert!(!task.is_finished());
for _ in 0..46 { for _ in 0..46 {
task.progress();
}
assert_eq!(task.get_movement_progress(), Some(50));
for _ in 46..91 {
task.progress();
}
assert_eq!(task.get_movement_progress(), Some(100));
assert_eq!(task.get_progress(), 100.0);
assert!(task.is_finished());
task.progress(); task.progress();
} assert_eq!(task.get_progress(), 100.0);
assert_eq!(task.get_movement_progress(), Some(50)); task.pop_front();
for _ in 46..91 { assert_eq!(task.get_movement_progress(), None);
task.progress(); assert_eq!(task.get_progress(), 0.0);
} assert!(!task.is_finished());
assert_eq!(task.get_movement_progress(), Some(100));
assert_eq!(task.get_progress(), 100.0);
assert!(task.is_finished());
task.progress();
assert_eq!(task.get_progress(), 100.0);
task.pop_front();
assert_eq!(task.get_movement_progress(), None);
assert_eq!(task.get_progress(), 0.0);
assert!(!task.is_finished());
} }
#[test] #[test]
fn overall_digging_progress_counts_completed_cells_and_current_step() -> () { fn overall_digging_progress_counts_completed_cells_and_current_step() -> () {
let mut task: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0); let mut task: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
assert_eq!(task.get_total_progress(), 0.0); assert_eq!(task.get_total_progress(), 0.0);
task.progress(); task.progress();
assert_eq!(task.get_progress(), 25.0); assert_eq!(task.get_progress(), 25.0);
assert_eq!(task.get_total_progress(), 12.5); assert_eq!(task.get_total_progress(), 12.5);
task.pop_front(); task.pop_front();
assert_eq!(task.get_progress(), 0.0); assert_eq!(task.get_progress(), 0.0);
assert_eq!(task.get_total_progress(), 50.0); assert_eq!(task.get_total_progress(), 50.0);
task.progress(); task.progress();
assert_eq!(task.get_total_progress(), 62.5); assert_eq!(task.get_total_progress(), 62.5);
task.pop_front(); task.pop_front();
assert_eq!(task.get_total_progress(), 100.0); assert_eq!(task.get_total_progress(), 100.0);
let empty: DiggingTask = DiggingTask::new([].into(), 1.0); let empty: DiggingTask = DiggingTask::new([].into(), 1.0);
assert_eq!(empty.get_total_progress(), 100.0); assert_eq!(empty.get_total_progress(), 100.0);
} }
#[test] #[test]
fn task_conversion_keeps_completed_steps_and_original_length() -> () { fn task_conversion_keeps_completed_steps_and_original_length() -> () {
let mut digging: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0); let mut digging: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
digging.pop_front(); digging.pop_front();
let mut moving: MovingTask = MovingTask::from_digging(digging); let mut moving: MovingTask = MovingTask::from_digging(digging);
assert_eq!(moving.get_total_steps(), 2); assert_eq!(moving.get_total_steps(), 2);
assert_eq!(moving.get_total_progress(), 50.0); assert_eq!(moving.get_total_progress(), 50.0);
for _ in 0..91 { for _ in 0..91 {
moving.progress(); moving.progress();
} }
assert_eq!(moving.get_total_progress(), 100.0); assert_eq!(moving.get_total_progress(), 100.0);
let mut digging: DiggingTask = DiggingTask::from_moving(moving, 25.0); let mut digging: DiggingTask = DiggingTask::from_moving(moving, 25.0);
assert_eq!(digging.get_total_steps(), 2); assert_eq!(digging.get_total_steps(), 2);
assert_eq!(digging.get_total_progress(), 50.0); assert_eq!(digging.get_total_progress(), 50.0);
digging.progress(); digging.progress();
assert_eq!(digging.get_total_progress(), 62.5); assert_eq!(digging.get_total_progress(), 62.5);
} }
#[test] #[test]
fn digging_queue_formats_step_total_and_pending_progress() -> () { fn digging_queue_formats_step_total_and_pending_progress() -> () {
let mut miner: MinerUnit = MinerUnit::new(Players::Player, (5, 2)); let mut miner: MinerUnit = MinerUnit::new(Players::Player, (5, 2));
let mut task: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0); let mut task: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
task.pop_front(); task.pop_front();
task.progress(); task.progress();
miner.set_task(Tasks::Digging(task)); miner.set_task(Tasks::Digging(task));
miner.set_task(Tasks::Moving(MovingTask::new([(5, 5)].into()))); miner.set_task(Tasks::Moving(MovingTask::new([(5, 5)].into())));
let lines: Vec<ratatui::text::Line<'_>> = miner.get_tasks_formatted(); let lines: Vec<ratatui::text::Line<'_>> = miner.get_tasks_formatted();
assert!(lines[1].to_string().contains("Step: 25% | Total: 62%")); assert!(lines[1].to_string().contains("Step: 25% | Total: 62%"));
assert!(lines[3].to_string().contains("Step: 0% | Total: 0%")); assert!(lines[3].to_string().contains("Step: 0% | Total: 0%"));
} }