Add support structures and stress mechanics
Nix package / build (push) Successful in 8m38s
Linux package builds (test) / linux (push) Successful in 2m19s
Linux package builds (test) / aur (push) Successful in 8m29s
Rust checks / check (push) Successful in 4m3s

Introduce support structures (roof supports) and a stress system that
propagates from bases through tunnels, causing collapses when stress
reaches 100 %.  Add auto‑support feature with toggleable tasks, new
keybindings (Tasks, AutoSupports, Stress), and a support‑building tab.
Implement collapsed tunnels, support work progress, and related UI
rendering (stress view, auto‑support indicators).  Extend board resize
logic, update game state structs, and adjust keybinding actions.  Update
documentation (README, AGENTS, CODE_MAP, PROGRESS, TESTING) to reflect
new features.  Add flake.nix dependencies (bash, gitea‑actions‑runner,
docker).  Add extensive tests for tunnel stress, support building,
auto‑supports, and related mechanics.
This commit is contained in:
GarandPLG committed 2026-10-24 19:33:38 +02:00
1 parent 110d4c411a
commit d293f95174
28 files changed
+1622 -73

No files matched your search

+4 -4
View File
@@ -56,10 +56,10 @@ impl App {
if let Ok((_, _)) = resize_rx.try_recv() {
let window_area: Rect = self.window_area;
if let Some(state) = self.states_mut() {
state
.skirmish
.board
.change_resize(&window_area, state.skirmish.side_panel);
state.skirmish.board.change_resize(
&window_area,
state.skirmish.side_panel || state.skirmish.task_view,
);
}
}
+30
View File
@@ -35,6 +35,9 @@ pub enum Action {
Building,
/// Open or close the recruitment view.
Recruitment,
Tasks,
AutoSupports,
Stress,
/// Unmark last selected cell.
Backspace,
/// Cancel marking cell.
@@ -112,6 +115,33 @@ pub struct KeyBinding {
/// The slice is ordered primarily for readability; the runtime lookup scans
/// this slice linearly, which is acceptable given the modest size of the list.
static KEYBINDINGS: &[KeyBinding] = &[
KeyBinding {
action: Action::Tasks,
code: KeyCode::Char('t'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Opener,
symbol: "t",
description: "Tasks",
},
KeyBinding {
action: Action::AutoSupports,
code: KeyCode::Char('a'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Opener,
symbol: "a",
description: "Auto supports",
},
KeyBinding {
action: Action::Stress,
code: KeyCode::Char('s'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Opener,
symbol: "s",
description: "Stress view",
},
KeyBinding {
action: Action::Recruitment,
code: KeyCode::Char('r'),
+94 -9
View File
@@ -13,11 +13,26 @@ use crate::app::{
use ratatui::{crossterm::event::KeyEvent, layout::Rect};
pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
if state.task_view {
let mut actions: Vec<Action> = vec![
Action::Tasks,
Action::Left,
Action::Right,
Action::Enter,
Action::Stress,
Action::Quit2,
];
if state.selected_task == 0 {
actions.push(Action::AutoSupports);
}
return actions;
}
if state.recruitment_view {
let mut actions: Vec<Action> = vec![
Action::Recruitment,
Action::Left,
Action::Right,
Action::Stress,
Action::Quit2,
];
if state.can_recruit_miner(state.board.get_focused_cell().get_coords(), Players::Player) {
@@ -26,9 +41,25 @@ pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
return actions;
}
if state.building_view {
let mut actions: Vec<Action> =
vec![Action::Building, Action::Left, Action::Right, Action::Quit2];
let mut actions: Vec<Action> = vec![
Action::Building,
Action::Left,
Action::Right,
Action::Stress,
Action::Quit2,
];
let coords: (usize, usize) = state.board.get_focused_cell().get_coords();
if state.selected_building == 1 {
if state.can_build_support(coords, Players::Player) {
actions.push(Action::Enter);
}
if state.can_manage_support(coords, Players::Player)
&& matches!(state.board.cells[coords.0][coords.1].get_structure(), Structures::Tunnel(tunnel) if tunnel.has_support() && tunnel.support_work().is_none())
{
actions.push(Action::Delete);
}
return actions;
}
if let Structures::MineConstruction(site) =
state.board.get_ref_cell(coords.0, coords.1).get_structure()
&& site.get_owner() == Players::Player
@@ -48,7 +79,12 @@ pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
Action::Esc,
Action::Tab,
Action::Quit2,
Action::Stress,
Action::Tasks,
]);
if !state.board.movement_only {
actions.push(Action::AutoSupports);
}
return actions;
}
actions.extend([
@@ -61,6 +97,7 @@ pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
Action::Mute,
Action::Quit,
Action::Esc,
Action::Stress,
]);
if state.board.zoom_level != ZoomLevel::ZoomedIn {
actions.push(Action::ZoomIn);
@@ -85,11 +122,41 @@ pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
}
pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () {
let window_area: Rect = app.window_area;
if let Some(action) = event_to_action(key_event) {
let Some(states) = app.states_mut() else {
common_keybindings(app, action);
return;
};
if action == Action::Stress {
states.skirmish.stress_view = !states.skirmish.stress_view;
return;
}
if states.skirmish.task_view {
match action {
Action::Tasks | Action::Enter => {
states.skirmish.task_view = false;
if action == Action::Enter {
states
.skirmish
.board
.configure_selected_task(states.skirmish.selected_task == 1);
}
states
.skirmish
.board
.change_resize(&window_area, states.skirmish.side_panel);
}
Action::Left => states.skirmish.cycle_popup_option(false),
Action::Right => states.skirmish.cycle_popup_option(true),
Action::AutoSupports if states.skirmish.selected_task == 0 => {
states.skirmish.board.auto_supports = !states.skirmish.board.auto_supports
}
Action::Esc => (),
_ => common_keybindings(app, action),
}
return;
}
if states.skirmish.recruitment_view {
match action {
Action::Recruitment => states.skirmish.recruitment_view = false,
@@ -114,16 +181,24 @@ pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () {
Action::Enter => {
let coords: (usize, usize) =
states.skirmish.board.get_focused_cell().get_coords();
states
.skirmish
.start_mine_construction(coords, Players::Player);
if states.skirmish.selected_building == 1 {
states.skirmish.build_support(coords, Players::Player);
} else {
states
.skirmish
.start_mine_construction(coords, Players::Player);
}
}
Action::Delete => {
let coords: (usize, usize) =
states.skirmish.board.get_focused_cell().get_coords();
states
.skirmish
.cancel_mine_construction(coords, Players::Player);
if states.skirmish.selected_building == 1 {
states.skirmish.remove_support(coords, Players::Player);
} else {
states
.skirmish
.cancel_mine_construction(coords, Players::Player);
}
}
_ => common_keybindings(app, action),
}
@@ -213,9 +288,19 @@ pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () {
}
ZoomLevel::ZoomedOut => {}
},
Action::Space => board.toggle_marking(),
Action::Space => {
if !board.marked_cells.marking_cells {
board.movement_only = false;
}
board.toggle_marking();
}
Action::Building => states.skirmish.building_view = true,
Action::Recruitment => states.skirmish.recruitment_view = true,
Action::Tasks => {
states.skirmish.task_view = true;
board.change_resize(&window_area, true);
}
Action::AutoSupports => board.auto_supports = !board.auto_supports,
Action::Backspace if board.marked_cells.marking_cells => board.pop_marked_cell(),
Action::Backspace => {
board.cancel_focused_tasks(false);
+3
View File
@@ -42,6 +42,9 @@ impl GameStates {
recruitment_view: false,
selected_building: 0,
selected_recruit: 0,
task_view: false,
stress_view: false,
selected_task: 0,
},
perk_decks: PerkDecksState {
id: 2,
+161 -6
View File
@@ -1,8 +1,9 @@
use crate::app::states::skirmish_states::{
BoardState, Players, Resources,
BoardState, Excavation, Players, Resources,
resources::{MINE_PRODUCTION_TICKS, WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_TICKS},
structures::{BuildingTerrain, Mine, Structure, Structures},
units::MinerUnit,
structures::{BuildingTerrain, Mine, Structure, Structures, Tunnel},
tasks::{Task, Tasks},
units::{MinerUnit, Unit},
};
use crate::app::widgets::CellWidget;
@@ -19,14 +20,163 @@ pub struct SkirmishState {
pub recruitment_view: bool,
pub selected_building: usize,
pub selected_recruit: usize,
pub task_view: bool,
pub stress_view: bool,
pub selected_task: usize,
}
impl SkirmishState {
pub const BUILDING_OPTIONS: &'static [&'static str] = &["Mine"];
pub const BUILDING_OPTIONS: &'static [&'static str] = &["Mine", "Support"];
pub const RECRUITMENT_OPTIONS: &'static [&'static str] = &["Miner"];
pub const TASK_OPTIONS: &'static [&'static str] = &["Digging", "Moving"];
pub fn can_manage_support(&self, coords: (usize, usize), owner: Players) -> bool {
owner != Players::Unclaimed
&& self
.board
.cells
.get(coords.0)
.and_then(|row: &Vec<CellWidget>| -> Option<&CellWidget> { row.get(coords.1) })
.is_some_and(|cell: &CellWidget| -> bool {
matches!(cell.get_structure(), Structures::Tunnel(_))
&& ((cell.get_ref_option_unit().get_owner() == owner
&& cell.get_ref_option_unit().get_can_build())
|| self.board.has_adjacent_builder(coords, owner))
})
}
pub fn can_build_support(&self, coords: (usize, usize), owner: Players) -> bool {
let resources: &Resources = match owner {
Players::Player => &self.player_resources,
Players::Enemy => &self.enemy_resources,
Players::Unclaimed => return false,
};
self.can_manage_support(coords, owner)
&& resources.can_afford(Tunnel::SUPPORT_COST)
&& matches!(self.board.cells[coords.0][coords.1].get_structure(), Structures::Tunnel(tunnel) if !tunnel.has_support() && tunnel.support_work().is_none())
}
pub fn build_support(&mut self, coords: (usize, usize), owner: Players) -> bool {
if !self.can_build_support(coords, owner) {
return false;
}
let Structures::Tunnel(mut tunnel) = self.board.cells[coords.0][coords.1].get_structure()
else {
return false;
};
let resources: &mut Resources = if owner == Players::Player {
&mut self.player_resources
} else {
&mut self.enemy_resources
};
if !resources.spend(Tunnel::SUPPORT_COST) {
return false;
}
tunnel.start_support_work(owner, false);
self.board.cells[coords.0][coords.1].set_structure(Structures::Tunnel(tunnel));
true
}
pub fn remove_support(&mut self, coords: (usize, usize), owner: Players) -> bool {
if !self.can_manage_support(coords, owner) {
return false;
}
let Structures::Tunnel(mut tunnel) = self.board.cells[coords.0][coords.1].get_structure()
else {
return false;
};
if !tunnel.has_support() || tunnel.support_work().is_some() {
return false;
}
tunnel.start_support_work(owner, true);
self.board.cells[coords.0][coords.1].set_structure(Structures::Tunnel(tunnel));
true
}
pub fn advance_support_work(&mut self) -> () {
let mut workers: std::collections::HashSet<(usize, usize)> =
std::collections::HashSet::new();
let mut finished: bool = false;
let mut removed: bool = false;
for row in 0..self.board.map_height {
for col in 0..self.board.map_width {
let Structures::Tunnel(mut tunnel) = self.board.cells[row][col].get_structure()
else {
continue;
};
let Some(work) = tunnel.support_work() else {
continue;
};
let Some(worker) = self.board.support_worker((row, col), work.owner) else {
continue;
};
if !workers.insert(worker) {
continue;
}
if let Some(removing) = tunnel.advance_support_work() {
finished = true;
removed |= removing;
}
self.board.cells[row][col].set_structure(Structures::Tunnel(tunnel));
}
}
if removed {
self.board.resolve_collapses();
} else if finished {
self.board.recalculate_stress();
}
}
fn prepare_automatic_supports(&mut self) -> () {
let mut candidates: Vec<((usize, usize), Players)> = Vec::new();
for (row, cells) in self.board.cells.iter().enumerate() {
for (col, cell) in cells.iter().enumerate() {
if !matches!(cell.get_structure(), Structures::Tunnel(tunnel) if !tunnel.has_support() && tunnel.get_stress() == 80)
{
continue;
}
if self.board.working_on_support((row, col))
|| (self.board.marked_cells.marking_cells
&& self.board.marked_cells.selected_unit.get_coords() == (row, col))
{
continue;
}
let Some(unit) = cell.get_ref_option_unit() else {
continue;
};
let Some(task) = unit.get_ref_tasks().front() else {
continue;
};
let Some(&(r, c)) = task.get_path().front() else {
continue;
};
if task.auto_supports()
&& self.can_build_support((row, col), unit.get_owner())
&& (matches!(task, Tasks::Digging(_))
|| matches!(task, Tasks::Moving(moving) if moving.dig_if_blocked))
&& r < self.board.map_height
&& c < self.board.map_width
&& row.abs_diff(r) + col.abs_diff(c) == 1
&& self.board.cells[r][c].get_ref_option_unit().is_none()
&& matches!(
self.board.cells[r][c].get_structure(),
Structures::Stone(_) | Structures::CollapsedTunnel(_)
)
&& self.board.prospective_tunnel_stress((r, c)) >= 100
{
candidates.push(((row, col), unit.get_owner()));
}
}
}
for (coords, owner) in candidates {
self.build_support(coords, owner);
}
}
pub fn cycle_popup_option(&mut self, next: bool) -> () {
let (selected, count): (&mut usize, usize) = if self.recruitment_view {
let (selected, count): (&mut usize, usize) = if self.task_view {
(&mut self.selected_task, Self::TASK_OPTIONS.len())
} else if self.recruitment_view {
(&mut self.selected_recruit, Self::RECRUITMENT_OPTIONS.len())
} else if self.building_view {
(&mut self.selected_building, Self::BUILDING_OPTIONS.len())
@@ -114,7 +264,12 @@ impl SkirmishState {
}
pub fn tick_update(&mut self) -> () {
self.board.advance_turn();
self.advance_support_work();
self.prepare_automatic_supports();
let excavated: Vec<Excavation> = self.board.advance_turn_with_events();
if !excavated.is_empty() {
self.board.resolve_collapses();
}
self.board.advance_recruitment();
self.board.advance_construction();
+86 -16
View File
@@ -17,6 +17,13 @@ use crate::app::{
use ratatui::layout::Rect;
use std::collections::VecDeque;
pub struct Excavation {
pub destination: (usize, usize),
pub origin: (usize, usize),
pub owner: Players,
pub auto_supports: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BoardState {
cells_area: Rect,
@@ -32,6 +39,8 @@ pub struct BoardState {
pub zoom_level: ZoomLevel,
focused_cell: FocusedCell,
pub marked_cells: MarkedCells,
pub auto_supports: bool,
pub movement_only: bool,
}
impl BoardState {
@@ -145,6 +154,7 @@ impl BoardState {
}
pub fn advance_construction(&mut self) -> () {
let mut opened: bool = false;
for row in 0..self.cells.len() {
for col in 0..self.cells[row].len() {
if let Structures::MineConstruction(mut site) = self.cells[row][col].get_structure()
@@ -152,6 +162,7 @@ impl BoardState {
{
site.advance();
let structure: Structures = if let Some(mine) = site.into_mine() {
opened = true;
Structures::Mine(mine)
} else {
Structures::MineConstruction(site)
@@ -160,6 +171,9 @@ impl BoardState {
}
}
}
if opened {
self.recalculate_stress();
}
}
pub fn new(
@@ -256,6 +270,8 @@ impl BoardState {
zoom_level,
focused_cell,
marked_cells,
auto_supports: true,
movement_only: false,
}
}
@@ -333,6 +349,29 @@ impl BoardState {
self.focused_cell.set_focused_cell(new);
}
pub fn configure_selected_task(&mut self, movement_only: bool) -> () {
if !self.marked_cells.marking_cells {
return;
}
if movement_only
&& self
.marked_cells
.marked_cells
.iter()
.skip(1)
.any(|&coords: &(usize, usize)| -> bool { !self.is_walkable(coords) })
{
let origin: (usize, usize) = self.marked_cells.selected_unit.get_coords();
let focused: (usize, usize) = self.focused_cell.get_coords();
self.end_marking_cells(true);
self.get_mut_cell(focused.0, focused.1).set_selected(false);
self.focused_cell.set_focused_cell(origin);
self.marked_cells.marking_cells = true;
self.start_marking_cells();
}
self.movement_only = movement_only;
}
pub fn start_marking_cells(&mut self) {
let (row, col) = self.focused_cell.get_coords();
@@ -386,12 +425,17 @@ impl BoardState {
let tasks: VecDeque<Tasks> = path
.chunk_by(|a, b| self.is_walkable(*a) == self.is_walkable(*b))
.map(|segment| {
let path = segment.iter().copied().collect();
if self.is_walkable(segment[0]) {
let path: VecDeque<(usize, usize)> = segment.iter().copied().collect();
let mut task: Tasks = if self.movement_only || self.is_walkable(segment[0]) {
Tasks::Moving(MovingTask::new(path))
} else {
Tasks::Digging(DiggingTask::new(path, tick_increase))
};
task.set_auto_supports(self.auto_supports);
if let Tasks::Moving(moving) = &mut task {
moving.dig_if_blocked = !self.movement_only;
}
task
})
.collect();
if let Some(unit) = self.get_mut_cell(row, col).get_mut_option_unit().as_mut() {
@@ -401,6 +445,7 @@ impl BoardState {
self.marked_cells.selected_unit = None;
self.marked_cells.marked_cells.clear();
self.movement_only = false;
}
fn max_offset(map_size: usize, size: usize) -> usize {
@@ -490,6 +535,10 @@ impl BoardState {
pub fn change_focused_cell(&mut self, direction: MoveFocusedCell) {
let old_cell: (usize, usize) = self.focused_cell.get_coords();
let new_cell: (usize, usize) = self.focused_cell.move_focused_cell(direction);
if self.marked_cells.marking_cells && self.movement_only && !self.is_walkable(new_cell) {
self.focused_cell.set_focused_cell(old_cell);
return;
}
self.get_mut_cell(old_cell.0, old_cell.1)
.set_selected(false);
@@ -519,7 +568,12 @@ impl BoardState {
true
}
pub fn advance_turn(&mut self) {
pub fn advance_turn(&mut self) -> () {
let _: Vec<Excavation> = self.advance_turn_with_events();
}
pub fn advance_turn_with_events(&mut self) -> Vec<Excavation> {
let mut excavated: Vec<Excavation> = Vec::new();
// Snapshot positions so moving to a later cell cannot grant another turn.
let active: Vec<(usize, usize)> = self
.cells
@@ -536,17 +590,18 @@ impl BoardState {
.collect();
for (row, col) in active {
if self.working_on_support((row, col)) {
continue;
}
// Keep the route's origin stable while the player is drawing it.
if self.marked_cells.marking_cells
&& self.marked_cells.selected_unit.get_coords() == (row, col)
{
continue;
}
let mut unit: Units = self
.get_mut_cell(row, col)
.get_mut_option_unit()
.take()
.unwrap();
let Some(mut unit) = self.get_mut_cell(row, col).get_mut_option_unit().take() else {
continue;
};
let mut task: Tasks = unit.get_mut_tasks().pop_front().unwrap();
let mut destination = (row, col);
@@ -570,24 +625,38 @@ impl BoardState {
(Tasks::Digging(dig), _) if walkable => {
Tasks::Moving(MovingTask::from_digging(dig))
}
(Tasks::Moving(moving), Structures::Stone(_)) => Tasks::Digging(
DiggingTask::from_moving(moving, unit.get_digging_power()),
),
(
Tasks::Moving(moving),
Structures::Stone(_) | Structures::CollapsedTunnel(_),
) if moving.dig_if_blocked => Tasks::Digging(DiggingTask::from_moving(
moving,
unit.get_digging_power(),
)),
(task, _) => task,
};
match (&mut task, structure) {
(Tasks::Digging(dig), Structures::Stone(mut stone)) => {
let max: f32 = f32::from(stone.get_max_durability());
(
Tasks::Digging(dig),
Structures::Stone(_) | Structures::CollapsedTunnel(_),
) => {
let mut obstacle: Structures = structure;
let max: f32 = f32::from(Stone::new().get_max_durability());
let before: u16 = (dig.get_progress() * max / 100.0) as u16;
dig.progress();
let after: u16 = (dig.get_progress() * max / 100.0) as u16;
stone.take_damage(after.saturating_sub(before));
if stone.get_durability() == 0 {
obstacle.take_damage(after.saturating_sub(before));
if obstacle.get_durability() == 0 {
cell.set_structure(Structures::Tunnel(Tunnel::new()));
excavated.push(Excavation {
destination: (target_row, target_col),
origin: (row, col),
owner: unit.get_owner(),
auto_supports: dig.auto_supports(),
});
destination = (target_row, target_col);
dig.pop_front();
} else {
cell.set_structure(Structures::Stone(stone));
cell.set_structure(obstacle);
}
}
(Tasks::Moving(moving), _) if walkable => {
@@ -609,5 +678,6 @@ impl BoardState {
.get_mut_cell(destination.0, destination.1)
.get_mut_option_unit() = Some(unit);
}
excavated
}
}
@@ -0,0 +1,166 @@
use super::{
BoardState, Players,
structures::{CollapsedTunnel, Structure, Structures},
units::{Unit, Units},
};
use crate::app::widgets::CellWidget;
use std::collections::VecDeque;
impl BoardState {
fn stress_distances(&self) -> Vec<Vec<u8>> {
let mut distance: Vec<Vec<u8>> = vec![vec![100; self.map_width]; self.map_height];
let mut queue: VecDeque<(usize, usize)> = VecDeque::new();
for (row, cells) in self.cells.iter().enumerate() {
for (col, cell) in cells.iter().enumerate() {
let source: Option<u8> = match cell.get_structure() {
Structures::Base(_) => Some(0),
Structures::Tunnel(tunnel) if tunnel.has_support() => Some(20),
_ => None,
};
if let Some(stress) = source {
distance[row][col] = stress;
queue.push_back((row, col));
}
}
}
while let Some((row, col)) = queue.pop_front() {
let next: u8 = distance[row][col].saturating_add(20).min(100);
for (r, c) in [
(row.wrapping_sub(1), col),
(row + 1, col),
(row, col.wrapping_sub(1)),
(row, col + 1),
] {
if r < self.map_height
&& c < self.map_width
&& next < distance[r][c]
&& matches!(
self.cells[r][c].get_structure(),
Structures::Tunnel(_) | Structures::Base(_) | Structures::Mine(_)
)
{
distance[r][c] = next;
queue.push_back((r, c));
}
}
}
distance
}
pub fn prospective_tunnel_stress(&self, (row, col): (usize, usize)) -> u8 {
let distance: Vec<Vec<u8>> = self.stress_distances();
[
(row.wrapping_sub(1), col),
(row + 1, col),
(row, col.wrapping_sub(1)),
(row, col + 1),
]
.into_iter()
.filter_map(|(r, c): (usize, usize)| -> Option<u8> {
distance
.get(r)
.and_then(|line: &Vec<u8>| -> Option<&u8> { line.get(c) })
.copied()
})
.min()
.unwrap_or(100)
.saturating_add(20)
.min(100)
}
pub fn recalculate_stress(&mut self) -> () {
let distance: Vec<Vec<u8>> = self.stress_distances();
for (row, cells) in self.cells.iter_mut().enumerate() {
for (col, cell) in cells.iter_mut().enumerate() {
if let Structures::Tunnel(mut tunnel) = cell.get_structure() {
tunnel.set_stress(distance[row][col]);
cell.set_structure(Structures::Tunnel(tunnel));
}
}
}
}
pub fn support_worker(
&self,
(row, col): (usize, usize),
owner: Players,
) -> Option<(usize, usize)> {
if owner == Players::Unclaimed {
return None;
}
[
(row, col),
(row.wrapping_sub(1), col),
(row + 1, col),
(row, col.wrapping_sub(1)),
(row, col + 1),
]
.into_iter()
.find(|&(r, c): &(usize, usize)| -> bool {
self.cells
.get(r)
.and_then(|line: &Vec<CellWidget>| -> Option<&CellWidget> { line.get(c) })
.and_then(|cell: &CellWidget| -> Option<&Units> {
cell.get_ref_option_unit().as_ref()
})
.is_some_and(|unit: &Units| -> bool {
unit.get_owner() == owner && unit.get_can_build()
})
})
}
pub fn working_on_support(&self, (row, col): (usize, usize)) -> bool {
[
(row, col),
(row.wrapping_sub(1), col),
(row + 1, col),
(row, col.wrapping_sub(1)),
(row, col + 1),
]
.into_iter()
.any(|(r, c): (usize, usize)| -> bool {
self.cells
.get(r)
.and_then(|line: &Vec<CellWidget>| -> Option<&CellWidget> { line.get(c) })
.is_some_and(|cell: &CellWidget| -> bool {
if let Structures::Tunnel(tunnel) = cell.get_structure()
&& let Some(work) = tunnel.support_work()
{
self.support_worker((r, c), work.owner) == Some((row, col))
} else {
false
}
})
})
}
pub fn resolve_collapses(&mut self) -> usize {
let mut total: usize = 0;
loop {
self.recalculate_stress();
let mut collapsed: usize = 0;
for row in &mut self.cells {
for cell in row {
if matches!(cell.get_structure(), Structures::Tunnel(tunnel) if tunnel.get_stress() >= 100)
{
cell.set_structure(Structures::CollapsedTunnel(CollapsedTunnel::new()));
cell.get_mut_option_unit().take();
collapsed += 1;
}
}
}
total += collapsed;
if collapsed == 0 {
break;
}
}
if self.marked_cells.marking_cells {
let (row, col): (usize, usize) = self.marked_cells.selected_unit.get_coords();
if self.cells[row][col].get_ref_option_unit().is_none() {
self.marked_cells.marking_cells = false;
self.end_marking_cells(true);
}
}
total
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
mod board;
mod board_stress;
mod focused_cell;
mod game_mode;
mod marked_cells;
@@ -10,7 +11,7 @@ pub mod tasks;
pub mod units;
pub mod zoom_level;
pub use board::BoardState;
pub use board::{BoardState, Excavation};
pub use focused_cell::{FocusedCell, MoveFocusedCell};
pub use game_mode::GameMode;
pub use marked_cells::MarkedCells;
@@ -0,0 +1,49 @@
use crate::app::states::skirmish_states::{Players, structures::Structure};
use ratatui::style::Color;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CollapsedTunnel {
durability: u16,
}
impl Default for CollapsedTunnel {
fn default() -> Self {
Self::new()
}
}
impl CollapsedTunnel {
pub fn new() -> Self {
Self { durability: 750 }
}
pub fn take_damage(&mut self, damage: u16) -> () {
self.durability = self.durability.saturating_sub(damage);
}
}
impl Structure for CollapsedTunnel {
fn get_tag(&self) -> char {
'#'
}
fn get_color(&self) -> Color {
Color::DarkGray
}
fn get_level(&self) -> char {
' '
}
fn get_stress(&self) -> u8 {
100
}
fn get_durability(&self) -> u16 {
self.durability
}
fn get_max_durability(&self) -> u16 {
750
}
fn get_name(&self) -> &'static str {
"Collapsed tunnel"
}
fn get_owner(&self) -> Players {
Players::Unclaimed
}
}
@@ -1,4 +1,5 @@
mod base;
mod collapsed_tunnel;
mod mine;
mod mine_construction;
mod ore;
@@ -8,6 +9,7 @@ mod structures_enum;
mod tunnel;
pub use base::BaseBuilding;
pub use collapsed_tunnel::CollapsedTunnel;
pub use mine::Mine;
pub use mine_construction::MineConstruction;
pub use ore::Ore;
@@ -1,6 +1,8 @@
use crate::app::states::skirmish_states::{
Players,
structures::{BaseBuilding, Mine, MineConstruction, Ore, Stone, Structure, Tunnel},
structures::{
BaseBuilding, CollapsedTunnel, Mine, MineConstruction, Ore, Stone, Structure, Tunnel,
},
units::Units,
};
use ratatui::{
@@ -13,12 +15,20 @@ pub enum Structures {
Base(BaseBuilding),
Tunnel(Tunnel),
Stone(Stone),
CollapsedTunnel(CollapsedTunnel),
Ore(Ore),
Mine(Mine),
MineConstruction(MineConstruction),
}
impl Structures {
pub fn take_damage(&mut self, damage: u16) -> () {
match self {
Self::Stone(stone) => stone.take_damage(damage),
Self::CollapsedTunnel(rubble) => rubble.take_damage(damage),
_ => (),
}
}
pub fn can_enter(&self, unit: &Units) -> bool {
match self {
Self::Base(_) | Self::Tunnel(_) => true,
@@ -42,6 +52,7 @@ impl Structures {
Self::Base(b) => b,
Self::Tunnel(t) => t,
Self::Stone(s) => s,
Self::CollapsedTunnel(rubble) => rubble,
Self::Ore(o) => o,
Self::Mine(mine) => mine,
Self::MineConstruction(site) => site,
@@ -1,21 +1,85 @@
use crate::app::states::skirmish_states::{Players, structures::Structure};
use ratatui::style::Color;
use crate::app::states::skirmish_states::{Players, Resources, structures::Structure};
use crate::app::threads::TICK_MS;
use ratatui::{
style::{Color, Stylize},
text::Line,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SupportWork {
pub owner: Players,
pub removing: bool,
ticks: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Tunnel {
durability: u16,
stress: u8,
roof_support: bool,
support_work: Option<SupportWork>,
rail: bool,
lamp: bool,
}
impl Tunnel {
pub const SUPPORT_WORK_TICKS: u16 = 3000_u16.div_ceil(TICK_MS as u16);
pub const SUPPORT_COST: Resources = Resources { wood: 10, metal: 2 };
pub fn has_support(&self) -> bool {
self.roof_support
}
pub fn support_work(&self) -> Option<SupportWork> {
self.support_work
}
pub fn start_support_work(&mut self, owner: Players, removing: bool) -> bool {
if owner == Players::Unclaimed
|| self.support_work.is_some()
|| self.roof_support != removing
{
return false;
}
self.support_work = Some(SupportWork {
owner,
removing,
ticks: 0,
});
true
}
pub fn support_progress(&self) -> Option<u16> {
self.support_work
.map(|work: SupportWork| -> u16 { work.ticks * 100 / Self::SUPPORT_WORK_TICKS })
}
pub fn advance_support_work(&mut self) -> Option<bool> {
let work: &mut SupportWork = self.support_work.as_mut()?;
work.ticks += 1;
if work.ticks < Self::SUPPORT_WORK_TICKS {
return None;
}
let removing: bool = work.removing;
self.roof_support = !removing;
self.support_work = None;
Some(removing)
}
pub fn set_support(&mut self, supported: bool) -> () {
self.roof_support = supported;
}
pub fn set_stress(&mut self, stress: u8) -> () {
self.stress = stress.min(100);
}
pub fn new() -> Self {
Self {
durability: 500,
stress: 25,
stress: 20,
roof_support: false,
support_work: None,
rail: false,
lamp: false,
}
@@ -23,6 +87,25 @@ impl Tunnel {
}
impl Structure for Tunnel {
fn text(&self) -> Vec<Line<'_>> {
let mut lines: Vec<Line<'_>> = self.base_text();
if let Some(work) = self.support_work {
lines.push(
Line::from(format!(
"{} support: {}%",
if work.removing {
"Removing"
} else {
"Building"
},
self.support_progress().unwrap_or(0)
))
.cyan(),
);
}
lines
}
fn get_tag(&self) -> char {
'T'
}
@@ -32,7 +115,7 @@ impl Structure for Tunnel {
}
fn get_level(&self) -> char {
' '
if self.roof_support { 'T' } else { ' ' }
}
fn get_durability(&self) -> u16 {
@@ -7,12 +7,14 @@ pub struct DiggingTask {
progress: f32,
tick_increase: f32,
total_steps: usize,
auto_supports: bool,
}
impl DiggingTask {
pub fn new(path: VecDeque<(usize, usize)>, tick_increase: f32) -> Self {
Self {
total_steps: path.len(),
auto_supports: true,
path,
progress: 0.0,
tick_increase,
@@ -30,6 +32,7 @@ impl DiggingTask {
progress: 0.0,
tick_increase,
total_steps: task.get_total_steps(),
auto_supports: task.auto_supports(),
}
}
@@ -39,6 +42,13 @@ impl DiggingTask {
}
impl Task for DiggingTask {
fn auto_supports(&self) -> bool {
self.auto_supports
}
fn set_auto_supports(&mut self, enabled: bool) -> () {
self.auto_supports = enabled;
}
fn get_icon(&self) -> char {
'⛏'
}
@@ -11,12 +11,16 @@ pub struct MovingTask {
path: VecDeque<(usize, usize)>,
ticks: u16,
total_steps: usize,
auto_supports: bool,
pub dig_if_blocked: bool,
}
impl MovingTask {
pub fn new(path: VecDeque<(usize, usize)>) -> Self {
Self {
total_steps: path.len(),
auto_supports: true,
dig_if_blocked: true,
path,
ticks: 0,
}
@@ -25,6 +29,8 @@ impl MovingTask {
pub fn from_digging(task: DiggingTask) -> Self {
Self {
total_steps: task.get_total_steps(),
auto_supports: task.auto_supports(),
dig_if_blocked: true,
path: task.get_path().clone(),
ticks: 0,
}
@@ -41,6 +47,13 @@ impl MovingTask {
}
impl Task for MovingTask {
fn auto_supports(&self) -> bool {
self.auto_supports
}
fn set_auto_supports(&mut self, enabled: bool) -> () {
self.auto_supports = enabled;
}
fn get_icon(&self) -> char {
'→'
}
@@ -1,6 +1,10 @@
use std::collections::VecDeque;
pub trait Task {
fn auto_supports(&self) -> bool;
fn set_auto_supports(&mut self, enabled: bool) -> ();
fn get_icon(&self) -> char;
fn get_path(&self) -> &VecDeque<(usize, usize)>;
@@ -26,6 +26,14 @@ impl Tasks {
}
impl Task for Tasks {
fn auto_supports(&self) -> bool {
self.t().auto_supports()
}
fn set_auto_supports(&mut self, enabled: bool) -> () {
self.t_mut().set_auto_supports(enabled);
}
fn get_icon(&self) -> char {
self.t().get_icon()
}
+131 -12
View File
@@ -6,7 +6,7 @@ use crate::app::{
states::skirmish_states::{
BoardState, Players,
resources::{WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_INTERVAL_MS},
structures::{Mine, Structure, Structures},
structures::{Mine, Structure, Structures, Tunnel},
units::MinerUnit,
},
widgets::{BoardWidget, CellWidget, KeybindingsWidget, SidePanelWidget},
@@ -23,7 +23,6 @@ pub fn skirmish_layout(area: Rect) -> [Rect; 3] {
Layout::vertical([
Constraint::Length(4),
Constraint::Fill(1),
// Four movement rows plus borders keep the board geometry stable across modes.
Constraint::Length(6),
])
.areas(area)
@@ -100,8 +99,10 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
let main_inner_area: Rect = main_block.inner(main_area);
main_block.render(main_area, buf);
let [board_area, side_panel_area] =
skirmish_main_area_layout(main_inner_area, states.skirmish.side_panel);
let [board_area, side_panel_area] = skirmish_main_area_layout(
main_inner_area,
states.skirmish.side_panel || states.skirmish.task_view,
);
let cells_area: Rect = board_area.inner(Margin {
horizontal: 1,
@@ -116,9 +117,43 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
// ),
// );
BoardWidget::new(&board).render(cells_area, buf);
BoardWidget::new(&board, states.skirmish.stress_view).render(cells_area, buf);
if states.skirmish.side_panel {
if states.skirmish.task_view {
let mut lines: Vec<Line<'_>> = vec![
Line::from("Enter: draw route").green(),
Line::from(format!(
"Auto supports: {}",
if board.auto_supports { "ON" } else { "OFF" }
))
.cyan(),
Line::from("a: toggle auto supports").yellow(),
Line::from("Support: 10 Wood + 2 Metal").yellow(),
Line::from("Placed at 80% stress").gray(),
Line::from("No funds: collapse risk!").red(),
Line::from("t: close task selection").gray(),
];
if states.skirmish.selected_task == 1 {
lines = vec![
Line::from("Enter: draw movement route").green(),
Line::from("Walkable cells only; no digging").cyan(),
Line::from("Collapsed route: wait, do not dig").gray(),
Line::from("t: close task selection").gray(),
];
}
let block: Block<'_> = Block::bordered()
.title("Unit tasks")
.padding(Padding::uniform(1));
let [tabs_area, details_area]: [Rect; 2] =
Layout::vertical([Constraint::Length(2), Constraint::Min(0)])
.areas(block.inner(side_panel_area));
block.render(side_panel_area, buf);
Tabs::new(SkirmishState::TASK_OPTIONS.iter().copied())
.select(states.skirmish.selected_task)
.highlight_style(Style::default().fg(Color::LightMagenta).bold())
.render(tabs_area, buf);
Paragraph::new(lines).render(details_area, buf);
} else if states.skirmish.side_panel {
let (row, col) = board.get_focused_cell().get_coords();
let mut is_unit_marked: bool = false;
@@ -138,10 +173,37 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
{
let actions: Vec<Action> = available_skirmish_actions(&states.skirmish);
let descriptions: [(Action, &'static str); 14] = [
let descriptions: [(Action, &'static str); 17] = [
(
Action::Tasks,
if states.skirmish.task_view {
"Close tasks"
} else {
"Unit tasks"
},
),
(
Action::AutoSupports,
if board.auto_supports {
"Auto supports: ON"
} else {
"Auto supports: OFF"
},
),
(
Action::Stress,
if states.skirmish.stress_view {
"Normal view"
} else {
"Stress view"
},
),
(
Action::Left,
if states.skirmish.building_view || states.skirmish.recruitment_view {
if states.skirmish.building_view
|| states.skirmish.recruitment_view
|| states.skirmish.task_view
{
"Previous option"
} else {
"Left"
@@ -149,7 +211,10 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
),
(
Action::Right,
if states.skirmish.building_view || states.skirmish.recruitment_view {
if states.skirmish.building_view
|| states.skirmish.recruitment_view
|| states.skirmish.task_view
{
"Next option"
} else {
"Right"
@@ -165,10 +230,16 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
),
(
Action::Enter,
if states.skirmish.recruitment_view {
if states.skirmish.task_view {
"Draw route"
} else if states.skirmish.recruitment_view {
"Queue Miner"
} else {
"Build Mine"
if states.skirmish.selected_building == 1 {
"Build support"
} else {
"Build Mine"
}
},
),
(
@@ -182,7 +253,11 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
(
Action::Delete,
if states.skirmish.building_view {
"Cancel construction"
if states.skirmish.selected_building == 1 {
"Remove support"
} else {
"Cancel construction"
}
} else if !board.marked_cells.marking_cells {
"Cancel all tasks"
} else {
@@ -261,6 +336,50 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
Line::from("Produces: 1 Metal/s (2 with a Miner inside)").cyan(),
];
lines.extend(status);
if states.skirmish.selected_building == 1 {
lines = vec![
Line::from(format!(
"Cost: {} Wood + {} Metal | Work time: 3s",
Tunnel::SUPPORT_COST.wood,
Tunnel::SUPPORT_COST.metal
))
.yellow(),
Line::from("Tunnel + nearby friendly builder required").gray(),
Line::from("Stress: 20% at support, +20% per tile").cyan(),
if matches!(structure, Structures::Tunnel(tunnel) if tunnel.support_work().is_some())
{
Line::from("Builder occupied until completion").yellow()
} else if matches!(structure, Structures::Tunnel(tunnel) if tunnel.has_support())
&& states.skirmish.can_manage_support(coords, Players::Player)
{
Line::from("Delete: remove support (no refund)").yellow()
} else if states.skirmish.can_build_support(coords, Players::Player) {
Line::from("Enter: build support").green()
} else {
Line::from("Support unavailable: site, builder or resources").red()
},
Line::from("WARNING: removal can cause fatal collapses").red(),
];
if let Structures::Tunnel(tunnel) = structure
&& let Some(work) = tunnel.support_work()
{
lines.push(
Line::from(format!(
"{}: {}%",
if work.removing {
"Removing support"
} else {
"Building support"
},
tunnel.support_progress().unwrap_or(0)
))
.cyan(),
);
if board.support_worker(coords, work.owner).is_none() {
lines.push(Line::from("Paused: friendly builder required").yellow());
}
}
}
Clear.render(popup, buf);
let block: Block<'_> = Block::bordered()
.title(Line::from("Buildings").magenta().bold())
+8 -3
View File
@@ -9,11 +9,12 @@ use std::{collections::HashSet, rc::Rc};
pub struct BoardWidget<'a> {
state: &'a BoardState,
stress: bool,
}
impl<'a> BoardWidget<'a> {
pub fn new(state: &'a BoardState) -> Self {
Self { state }
pub fn new(state: &'a BoardState, stress: bool) -> Self {
Self { state, stress }
}
}
@@ -60,7 +61,11 @@ impl Widget for BoardWidget<'_> {
if queued_path.contains(&coords) {
cell.set_marked(true);
}
cell.render(*cell_area, buf);
if self.stress {
cell.render_stress(*cell_area, buf);
} else {
cell.render(*cell_area, buf);
}
});
}
}
+48 -5
View File
@@ -24,6 +24,41 @@ pub struct CellWidget {
}
impl CellWidget {
pub fn render_stress(&self, area: Rect, buf: &mut Buffer) -> () {
if matches!(self.structure, Structures::Stone(_)) {
self.clone()
.set_marked(self.selected && self.marked)
.clone()
.render(area, buf);
return;
}
let stress: u8 = self.structure.get_stress();
let red: u8 = (u16::from(stress) * 255 / 100) as u8;
let color: Color = if self.selected {
self.fg_color()
} else {
Color::Rgb(red, 255 - red, 30)
};
let block: Block<'_> = Block::bordered().border_type(BorderType::Rounded).fg(color);
let inner: Rect = block.inner(area);
block.render(area, buf);
if inner.height == 0 || inner.width == 0 {
return;
}
let text: String = if stress == 100 && inner.width < 4 {
"100".to_string()
} else {
format!("{stress}%")
};
Paragraph::new(text)
.fg(color)
.alignment(Alignment::Center)
.render(
Rect::new(inner.x, inner.y + inner.height / 2, inner.width, 1),
buf,
);
}
pub fn new(
row: usize,
col: usize,
@@ -124,12 +159,14 @@ impl CellWidget {
.to_string()
.fg(self.unit.get_owner().get_color()),
);
if let Structures::Stone(stone) = self.structure
&& stone.get_durability() < stone.get_max_durability()
if matches!(
self.structure,
Structures::Stone(_) | Structures::CollapsedTunnel(_)
) && self.structure.get_durability() < self.structure.get_max_durability()
{
let percent: u32 =
u32::from(stone.get_durability()) * 100 / u32::from(stone.get_max_durability());
let middle = lines.len() / 2;
let percent: u32 = u32::from(self.structure.get_durability()) * 100
/ u32::from(self.structure.get_max_durability());
let middle: usize = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).cyan();
}
if let Structures::MineConstruction(site) = self.structure {
@@ -155,6 +192,12 @@ impl CellWidget {
};
lines[middle] = Line::from(label).cyan();
}
if let Structures::Tunnel(tunnel) = self.structure
&& let Some(percent) = tunnel.support_progress()
{
let middle: usize = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).cyan();
}
lines
}