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

+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()
}