Rename iron to metal, add mining and recruitment
Nix package / build (push) Successful in 9m39s
Rust checks / check (push) Successful in 3m44s
Linux package builds (test) / linux (push) Successful in 2m22s
Linux package builds (test) / aur (push) Successful in 7m21s

- Updated CLI, README and documentation to use `--starting-metal` with
  `--starting-iron` retained as an alias.
- Introduced `Resources` struct and constants for wood delivery and mine
  production.
- Added new structures `Mine` and `MineConstruction` with cost,
  production
  and cancel logic.
- Implemented building view for constructing mines and recruitment view
  for spawning miners.
- Extended keybindings with `Building` and `Recruitment` actions and
  updated
  action availability logic.
- Modified game state, board, units and UI widgets to handle metal
  resources
  and new construction mechanics.
- Updated tests and increased total test count to 82, covering
  resources,
  construction, recruitment and context‑aware keybindings.
This commit is contained in:
GarandPLG committed 2026-10-19 17:41:22 +02:00
1 parent 305cc2c4a2
commit a981663152
35 files changed
+2160 -74

No files matched your search

+145 -11
View File
@@ -4,7 +4,9 @@ use crate::app::{
FocusedCell, Offset,
skirmish_states::{
CellSizes, MarkedCells, MoveFocusedCell, Players, ZoomLevel,
structures::{BaseBuilding, Ore, Stone, Structure, Structures, Tunnel},
structures::{
BaseBuilding, BuildingTerrain, MineConstruction, Ore, Stone, Structure, Structures, Tunnel,
},
tasks::{DiggingTask, MovingTask, Task, Tasks},
units::{MinerUnit, Unit, Units},
},
@@ -32,6 +34,138 @@ pub struct BoardState {
}
impl BoardState {
pub fn advance_recruitment(&mut self) -> () {
for (row, cells) in self.cells.iter_mut().enumerate() {
for (col, cell) in cells.iter_mut().enumerate() {
if let Structures::Base(mut base) = cell.get_structure() {
if base.advance_recruitment(cell.get_ref_option_unit().is_some()) {
*cell.get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(base.get_owner(), (row, col))));
}
cell.set_structure(Structures::Base(base));
}
}
}
}
fn adjacent_cells(&self, (row, col): (usize, usize)) -> Vec<&CellWidget> {
let coordinates: [(usize, usize); 4] = [
(row.wrapping_sub(1), col),
(row.saturating_add(1), col),
(row, col.wrapping_sub(1)),
(row, col.saturating_add(1)),
];
coordinates
.into_iter()
.filter_map(|(row, col): (usize, usize)| -> Option<&CellWidget> {
self
.cells
.get(row)
.and_then(|cells: &Vec<CellWidget>| -> Option<&CellWidget> { cells.get(col) })
})
.collect()
}
pub fn has_adjacent_builder(&self, coords: (usize, usize), owner: Players) -> bool {
owner != Players::Unclaimed
&& self
.adjacent_cells(coords)
.iter()
.any(|cell: &&CellWidget| -> bool {
let unit: &Option<Units> = cell.get_ref_option_unit();
unit.get_owner() == owner && unit.get_can_build()
})
}
pub fn can_build_at(
&self,
(row, col): (usize, usize),
owner: Players,
terrain: BuildingTerrain,
) -> bool {
if owner == Players::Unclaimed {
return false;
}
let Some(cell) = self
.cells
.get(row)
.and_then(|cells: &Vec<CellWidget>| -> Option<&CellWidget> { cells.get(col) })
else {
return false;
};
if cell.get_ref_option_unit().is_some() {
return false;
}
if !self.has_adjacent_builder((row, col), owner) {
return false;
}
match (terrain, cell.get_structure()) {
(BuildingTerrain::Ore, Structures::Ore(ore)) => {
ore.get_owner() == Players::Unclaimed && ore.get_ore_amount() > 0
}
(BuildingTerrain::Stone, Structures::Stone(_)) => {
self
.adjacent_cells((row, col))
.iter()
.any(|cell: &&CellWidget| -> bool {
matches!(cell.get_structure(), Structures::Tunnel(_))
|| matches!(cell.get_structure(), Structures::Base(_))
&& cell.get_structure().get_owner() == owner
})
}
_ => false,
}
}
pub fn start_mine_construction(&mut self, coords: (usize, usize), owner: Players) -> bool {
if !self.can_build_at(coords, owner, BuildingTerrain::Ore) {
return false;
}
let cell: &mut CellWidget = self.get_mut_cell(coords.0, coords.1);
if let Structures::Ore(ore) = cell.get_structure()
&& let Some(site) = MineConstruction::new(owner, ore)
{
cell.set_structure(Structures::MineConstruction(site));
return true;
}
false
}
pub fn cancel_mine_construction(&mut self, (row, col): (usize, usize), owner: Players) -> bool {
let Some(cell): Option<&mut CellWidget> = self
.cells
.get_mut(row)
.and_then(|cells: &mut Vec<CellWidget>| -> Option<&mut CellWidget> { cells.get_mut(col) })
else {
return false;
};
if let Structures::MineConstruction(site) = cell.get_structure()
&& site.get_owner() == owner
{
cell.set_structure(Structures::Ore(site.cancel()));
return true;
}
false
}
pub fn advance_construction(&mut self) -> () {
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()
&& self.has_adjacent_builder((row, col), site.get_owner())
{
site.advance();
let structure: Structures = if let Some(mine) = site.into_mine() {
Structures::Mine(mine)
} else {
Structures::MineConstruction(site)
};
self.cells[row][col].set_structure(structure);
}
}
}
}
pub fn new(
area: &Rect,
map_width: usize,
@@ -79,9 +213,9 @@ impl BoardState {
} else if enemy_base {
Structures::Base(BaseBuilding::new(Players::Enemy))
} else if player_ore {
Structures::Ore(Ore::new(Players::Player, 1))
Structures::Ore(Ore::new(Players::Unclaimed, 1))
} else if enemy_ore {
Structures::Ore(Ore::new(Players::Enemy, 1))
Structures::Ore(Ore::new(Players::Unclaimed, 1))
} else {
Structures::Stone(Stone::new())
};
@@ -286,10 +420,11 @@ impl BoardState {
.get(row)
.and_then(|cells| cells.get(col))
.is_some_and(|cell| {
matches!(
cell.get_structure(),
Structures::Tunnel(_) | Structures::Base(_)
)
self
.marked_cells
.selected_unit
.as_ref()
.is_some_and(|unit: &Units| -> bool { cell.get_structure().can_enter(unit) })
})
}
@@ -419,11 +554,10 @@ impl BoardState {
let cell: &mut CellWidget = self.get_mut_cell(target_row, target_col);
if cell.get_ref_option_unit().is_none() {
let structure: Structures = cell.get_structure();
let walkable: bool = structure.can_enter(&unit);
// Previously planned stone may already be a tunnel when this task starts.
task = match (task, structure) {
(Tasks::Digging(dig), Structures::Tunnel(_) | Structures::Base(_)) => {
Tasks::Moving(MovingTask::from_digging(dig))
}
(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()))
}
@@ -444,7 +578,7 @@ impl BoardState {
cell.set_structure(Structures::Stone(stone));
}
}
(Tasks::Moving(moving), Structures::Tunnel(_) | Structures::Base(_)) => {
(Tasks::Moving(moving), _) if walkable => {
moving.progress();
if moving.is_finished() {
destination = (target_row, target_col);
+2
View File
@@ -4,6 +4,7 @@ mod game_mode;
mod marked_cells;
mod offset;
mod players;
pub mod resources;
pub mod structures;
pub mod tasks;
pub mod units;
@@ -15,4 +16,5 @@ pub use game_mode::GameMode;
pub use marked_cells::MarkedCells;
pub use offset::Offset;
pub use players::Players;
pub use resources::Resources;
pub use zoom_level::{CellSizes, ZoomLevel};
@@ -0,0 +1,41 @@
use crate::app::threads::TICK_MS;
pub const WOOD_DELIVERY_AMOUNT: u32 = 5;
pub const WOOD_DELIVERY_INTERVAL_MS: u64 = 5000;
pub const WOOD_DELIVERY_TICKS: u64 = WOOD_DELIVERY_INTERVAL_MS.div_ceil(TICK_MS as u64);
pub const MINE_PRODUCTION_TICKS: u64 = 1000_u64.div_ceil(TICK_MS as u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Resources {
pub wood: u32,
pub metal: u32,
}
impl Resources {
pub fn can_afford(&self, cost: Self) -> bool {
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;
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) -> () {
self.wood = self.wood.saturating_add(amount);
}
pub fn add_metal(&mut self, amount: u32) -> () {
self.metal = self.metal.saturating_add(amount);
}
}
@@ -1,5 +1,6 @@
use crate::app::states::skirmish_states::{Players, structures::Structure};
use ratatui::style::Color;
use crate::app::threads::TICK_MS;
use ratatui::{style::Color, text::Line};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BaseBuilding {
@@ -7,15 +8,78 @@ pub struct BaseBuilding {
stress: u8,
owner: Players,
level: u8,
queued_miners: u32,
recruitment_ticks: u16,
waiting_for_departure: bool,
}
impl BaseBuilding {
pub const RECRUITMENT_TICKS: u16 = 10000_u16.div_ceil(TICK_MS as u16);
pub fn queued_miners(&self) -> u32 {
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 {
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,
}
}
}
@@ -56,4 +120,19 @@ impl Structure for BaseBuilding {
fn get_owner(&self) -> Players {
self.owner
}
fn text(&self) -> Vec<Line<'_>> {
let mut lines: Vec<Line<'_>> = self.base_text();
lines.push(Line::from(format!(
"Recruitment queue: {}",
self.queued_miners
)));
if self.queued_miners > 0 {
lines.push(Line::from(format!(
"Miner progress: {}%",
self.recruitment_progress()
)));
}
lines
}
}
@@ -0,0 +1,96 @@
use crate::app::states::skirmish_states::{
Players, Resources,
structures::{Ore, Structure},
units::{Unit, Units},
};
use ratatui::{
style::{Color, Stylize},
text::Line,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Mine {
owner: Players,
ore: Ore,
}
impl Mine {
pub const COST: Resources = Resources {
wood: 20,
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 fn produce_with_miner(&mut self, staffed: bool) -> u16 {
self.ore.extract(self.production_per_cycle(staffed))
}
}
impl Structure for Mine {
fn get_tag(&self) -> char {
'M'
}
fn get_color(&self) -> Color {
self.owner.get_color()
}
fn get_level(&self) -> char {
self.ore.get_level()
}
fn get_stress(&self) -> u8 {
self.ore.get_stress()
}
fn get_durability(&self) -> u16 {
self.ore.get_durability()
}
fn get_max_durability(&self) -> u16 {
self.ore.get_max_durability()
}
fn get_name(&self) -> &'static str {
"Mine"
}
fn get_owner(&self) -> Players {
self.owner
}
fn text(&self) -> Vec<Line<'_>> {
let mut lines: Vec<Line<'_>> = self.base_text();
lines.push(Line::from_iter([
"Ore: ".gray(),
self.get_ore_amount().to_string().cyan(),
format!("/{}", self.ore.get_max_ore_amount()).gray(),
]));
lines
}
}
@@ -0,0 +1,75 @@
use crate::app::{
states::skirmish_states::{
Players,
structures::{Mine, Ore, Structure},
},
threads::TICK_MS,
};
use ratatui::{style::Color, text::Line};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MineConstruction {
mine: Mine,
ticks: u16,
}
impl MineConstruction {
pub const BUILD_TICKS: u16 = 10000_u16.div_ceil(TICK_MS as u16);
pub fn new(owner: Players, ore: Ore) -> Option<Self> {
Mine::new(owner, ore).map(|mine: Mine| -> Self { Self { mine, ticks: 0 } })
}
pub fn advance(&mut self) -> () {
self.ticks = self.ticks.saturating_add(1).min(Self::BUILD_TICKS);
}
pub fn is_finished(&self) -> bool {
self.ticks == Self::BUILD_TICKS
}
pub fn progress_percent(&self) -> u16 {
(u32::from(self.ticks) * 100 / u32::from(Self::BUILD_TICKS)) as u16
}
pub fn into_mine(self) -> Option<Mine> {
self.is_finished().then_some(self.mine)
}
pub fn cancel(self) -> Ore {
self.mine.into_ore()
}
}
impl Structure for MineConstruction {
fn get_tag(&self) -> char {
'C'
}
fn get_color(&self) -> Color {
self.mine.get_color()
}
fn get_level(&self) -> char {
self.mine.get_level()
}
fn get_stress(&self) -> u8 {
self.mine.get_stress()
}
fn get_durability(&self) -> u16 {
self.mine.get_durability()
}
fn get_max_durability(&self) -> u16 {
self.mine.get_max_durability()
}
fn get_name(&self) -> &'static str {
"Mine construction"
}
fn get_owner(&self) -> Players {
self.mine.get_owner()
}
fn text(&self) -> Vec<Line<'_>> {
vec![
Line::from(format!("Construction: {}%", self.progress_percent())),
Line::from(format!("Ore: {}", self.mine.get_ore_amount())),
]
}
}
@@ -1,4 +1,6 @@
mod base;
mod mine;
mod mine_construction;
mod ore;
mod stone;
mod structure_trait;
@@ -6,8 +8,16 @@ mod structures_enum;
mod tunnel;
pub use base::BaseBuilding;
pub use mine::Mine;
pub use mine_construction::MineConstruction;
pub use ore::Ore;
pub use stone::Stone;
pub use structure_trait::Structure;
pub use structures_enum::Structures;
pub use tunnel::Tunnel;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuildingTerrain {
Stone,
Ore,
}
@@ -32,6 +32,12 @@ impl Ore {
5000
}
pub fn extract(&mut self, amount: u16) -> u16 {
let extracted: u16 = amount.min(self.ore_amount);
self.ore_amount -= extracted;
extracted
}
pub fn set_owner(&mut self, new_owner: Players) -> &mut Self {
self.owner = new_owner;
self
@@ -1,6 +1,7 @@
use crate::app::states::skirmish_states::{
Players,
structures::{BaseBuilding, Ore, Stone, Structure, Tunnel},
structures::{BaseBuilding, Mine, MineConstruction, Ore, Stone, Structure, Tunnel},
units::Units,
};
use ratatui::{
style::{Color, Stylize},
@@ -13,9 +14,28 @@ pub enum Structures {
Tunnel(Tunnel),
Stone(Stone),
Ore(Ore),
Mine(Mine),
MineConstruction(MineConstruction),
}
impl Structures {
pub fn can_enter(&self, unit: &Units) -> bool {
match self {
Self::Base(_) | Self::Tunnel(_) => true,
Self::Mine(mine) => mine.accepts_unit(unit),
_ => false,
}
}
pub fn build_mine(&mut self, owner: Players) -> bool {
if let Self::Ore(ore) = *self
&& let Some(mine) = Mine::new(owner, ore)
{
*self = Self::Mine(mine);
return true;
}
false
}
#[inline]
fn s(&self) -> &dyn Structure {
match self {
@@ -23,6 +43,8 @@ impl Structures {
Self::Tunnel(t) => t,
Self::Stone(s) => s,
Self::Ore(o) => o,
Self::Mine(mine) => mine,
Self::MineConstruction(site) => site,
}
}
}
@@ -1,7 +1,7 @@
use ratatui::{style::Stylize, text::Line};
use crate::app::states::skirmish_states::{
Players,
Players, Resources,
tasks::{Task, Tasks},
units::Unit,
};
@@ -18,6 +18,10 @@ pub struct MinerUnit {
}
impl MinerUnit {
pub const RECRUIT_COST: Resources = Resources {
wood: 10,
metal: 15,
};
pub fn new(owner: Players, coords: (usize, usize)) -> Self {
Self {
owner,
@@ -71,6 +75,10 @@ impl Unit for MinerUnit {
self.can_dig
}
fn get_can_build(&self) -> bool {
true
}
fn get_digging_power(&self) -> f32 {
self.digging_power
}
@@ -22,6 +22,10 @@ pub trait Unit {
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);
@@ -66,6 +70,10 @@ pub trait Unit {
]));
}
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 {
@@ -28,6 +28,9 @@ impl Units {
}
impl Unit for Units {
fn get_can_build(&self) -> bool {
self.u().get_can_build()
}
fn get_tag(&self) -> char {
self.u().get_tag()
}
@@ -82,6 +85,11 @@ impl Unit for Units {
}
impl Unit for Option<Units> {
fn get_can_build(&self) -> bool {
self
.as_ref()
.is_some_and(|unit: &Units| -> bool { unit.get_can_build() })
}
fn is_unit(&self) -> bool {
self.as_ref().map_or(false, |u| u.is_unit())
}