pub const DUST_REACH: u32 = 15;
pub const DUST_BUDGET: u32 = DUST_REACH - 1;
pub const CROSSING_ALLOWANCE: u32 = 1;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Carrier {
#[default]
Dust,
Refreshing,
Gaining {
per_stage: u8,
},
}
impl Carrier {
pub fn decay_per_cell(self) -> u32 {
match self {
Carrier::Dust => 1,
Carrier::Refreshing | Carrier::Gaining { .. } => 0,
}
}
pub fn plans_stations(self) -> bool {
matches!(self, Carrier::Dust)
}
pub fn gain_per_stage(self) -> u8 {
match self {
Carrier::Gaining { per_stage } => per_stage,
_ => 0,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct DecayLedger {
carrier: Carrier,
budget: u32,
spent: u32,
reserve: u32,
}
impl DecayLedger {
pub fn fresh(carrier: Carrier) -> Self {
Self::entering(carrier, 0)
}
pub fn entering(carrier: Carrier, spent: u32) -> Self {
DecayLedger {
carrier,
budget: DUST_BUDGET,
spent,
reserve: 0,
}
}
pub fn reserving(mut self, reserve: u32) -> Self {
self.reserve = reserve;
self
}
pub fn allowing(mut self, cells: u32) -> Self {
self.budget = self.budget.saturating_sub(cells);
self
}
pub fn carrier(&self) -> Carrier {
self.carrier
}
pub fn spent(&self) -> u32 {
self.spent
}
pub fn reserve(&self) -> u32 {
self.reserve
}
pub fn carry_cell(&mut self) {
self.spent = self.spent.saturating_add(self.carrier.decay_per_cell());
}
pub fn refresh(&mut self) {
self.spent = 0;
}
pub fn admits_cell(&self, mandatory_tail: u32) -> bool {
let d = self.carrier.decay_per_cell();
if d == 0 {
return true;
}
self.spent + d * (1 + mandatory_tail) <= self.budget
}
pub fn needs_station(&self, mandatory_tail: u32) -> bool {
self.carrier.plans_stations() && !self.admits_cell(mandatory_tail)
}
}
pub fn tile_dust_debt<F>(anchor: (i32, i32, i32), is_dust: F) -> u32
where
F: Fn((i32, i32, i32)) -> bool,
{
if !is_dust(anchor) {
return 0;
}
const STEPS: [(i32, i32, i32); 14] = [
(1, 0, 0),
(-1, 0, 0),
(0, 1, 0),
(0, -1, 0),
(0, 0, 1),
(0, 0, -1),
(1, 1, 0),
(-1, 1, 0),
(1, -1, 0),
(-1, -1, 0),
(0, 1, 1),
(0, 1, -1),
(0, -1, 1),
(0, -1, -1),
];
let mut seen = std::collections::BTreeSet::new();
let mut frontier = vec![anchor];
seen.insert(anchor);
let mut depth = 0u32;
while !frontier.is_empty() {
depth += 1;
let mut next = Vec::new();
for p in frontier {
for s in STEPS {
let q = (p.0 + s.0, p.1 + s.1, p.2 + s.2);
if seen.contains(&q) || !is_dust(q) {
continue;
}
seen.insert(q);
next.push(q);
}
}
frontier = next;
}
depth
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dust_budget_is_one_short_of_the_physics() {
assert_eq!(DUST_REACH, 15);
assert_eq!(DUST_BUDGET, 14);
}
#[test]
fn a_fresh_dust_ledger_carries_the_whole_budget() {
let mut l = DecayLedger::fresh(Carrier::Dust);
let mut cells = 0;
while !l.needs_station(0) {
l.carry_cell();
cells += 1;
assert!(cells <= 100, "runaway");
}
assert_eq!(cells, DUST_BUDGET, "14 dust cells, then a station");
assert_eq!(l.spent(), DUST_BUDGET);
}
#[test]
fn an_entry_debt_shortens_the_first_stretch() {
let mut l = DecayLedger::entering(Carrier::Dust, 7);
let mut cells = 0;
while !l.needs_station(0) {
l.carry_cell();
cells += 1;
}
assert_eq!(cells, DUST_BUDGET - 7);
}
#[test]
fn a_reserve_shortens_the_last_stretch_by_exactly_itself() {
let mut l = DecayLedger::fresh(Carrier::Dust).reserving(7);
let mut cells = 0;
while !l.needs_station(7) {
l.carry_cell();
cells += 1;
}
assert_eq!(cells, DUST_BUDGET - 7);
assert_eq!(l.reserve(), 7);
}
#[test]
fn a_refresh_returns_the_whole_budget() {
let mut l = DecayLedger::entering(Carrier::Dust, 13);
assert!(l.admits_cell(0));
l.carry_cell();
assert!(l.needs_station(0));
l.refresh();
assert_eq!(l.spent(), 0);
assert!(l.admits_cell(0));
}
#[test]
fn a_mandatory_tail_forces_the_station_earlier() {
let l = DecayLedger::entering(Carrier::Dust, 10);
assert!(l.needs_station(4));
assert!(!l.needs_station(3));
}
#[test]
fn a_crossing_allowance_holds_back_the_budget_everywhere() {
let mut l = DecayLedger::fresh(Carrier::Dust).allowing(CROSSING_ALLOWANCE);
let mut cells = 0;
while !l.needs_station(0) {
l.carry_cell();
cells += 1;
assert!(cells <= 100, "runaway");
}
assert_eq!(cells, DUST_BUDGET - CROSSING_ALLOWANCE);
let l = DecayLedger::entering(Carrier::Dust, DUST_BUDGET - CROSSING_ALLOWANCE)
.allowing(CROSSING_ALLOWANCE);
assert!(l.needs_station(0));
}
#[test]
fn a_refreshing_carrier_never_plans_a_station() {
let mut l = DecayLedger::fresh(Carrier::Refreshing);
for _ in 0..1000 {
assert!(!l.needs_station(50));
l.carry_cell();
}
assert_eq!(l.spent(), 0, "a refreshing carrier spends nothing");
}
#[test]
fn a_gaining_carrier_never_plans_a_station() {
let c = Carrier::Gaining { per_stage: 4 };
assert_eq!(c.gain_per_stage(), 4);
assert_eq!(c.decay_per_cell(), 0);
assert!(!c.plans_stations());
let mut l = DecayLedger::entering(c, 12).reserving(3);
for _ in 0..1000 {
assert!(!l.needs_station(50));
l.carry_cell();
}
}
#[test]
fn debt_counts_dust_cells_up_to_a_refresher() {
let dust: std::collections::BTreeSet<(i32, i32, i32)> =
(2..=8).map(|x| (x, 0, 0)).collect();
assert_eq!(tile_dust_debt((8, 0, 0), |p| dust.contains(&p)), 7);
assert_eq!(tile_dust_debt((5, 0, 0), |p| dust.contains(&p)), 4);
assert_eq!(tile_dust_debt((99, 0, 0), |p| dust.contains(&p)), 0);
}
#[test]
fn debt_climbs_a_staircase() {
let dust: std::collections::BTreeSet<(i32, i32, i32)> = (0..5).map(|i| (i, i, 0)).collect();
assert_eq!(tile_dust_debt((4, 4, 0), |p| dust.contains(&p)), 5);
}
}