moirai-for-games 0.1.0

A small deterministic no_std ECS for constrained and headless games
Documentation
use super::*;
use rstest::rstest;

#[derive(Copy, Clone, Debug, Eq, PartialEq)]
enum RefState {
    Free,
    #[allow(dead_code)]
    Reserved,
    Live,
    Retired,
}

struct RefModel {
    generations: Vec<u32>,
    states: Vec<RefState>,
    free: Vec<u32>,
    next: u32,
}

impl RefModel {
    fn new() -> Self {
        Self {
            generations: Vec::new(),
            states: Vec::new(),
            free: Vec::new(),
            next: 0,
        }
    }

    fn alloc(&mut self) -> EntityId {
        let slot = if let Some(slot) = self.free.pop() {
            slot as usize
        } else {
            let slot = self.next;
            self.next += 1;
            self.generations.push(INITIAL_GENERATION);
            self.states.push(RefState::Free);
            slot as usize
        };
        self.states[slot] = RefState::Live;
        EntityId::from_parts(slot as u32, self.generations[slot])
    }

    fn free(&mut self, id: EntityId) -> bool {
        let slot = id.slot() as usize;
        if slot >= self.generations.len()
            || self.states[slot] != RefState::Live
            || self.generations[slot] != id.generation()
        {
            return false;
        }
        let next = self.generations[slot].checked_add(1);
        match next {
            Some(0) | None => {
                self.states[slot] = RefState::Retired;
                false
            }
            Some(gen) => {
                self.generations[slot] = gen;
                self.states[slot] = RefState::Free;
                self.free.push(slot as u32);
                true
            }
        }
    }

    fn is_alive(&self, id: EntityId) -> bool {
        let slot = id.slot() as usize;
        slot < self.generations.len()
            && self.states[slot] == RefState::Live
            && self.generations[slot] == id.generation()
    }
}

#[rstest]
fn initial_generation_is_one() {
    let mut alloc = EntityAllocator::new();
    let id = alloc.alloc();
    assert_eq!(id.generation(), INITIAL_GENERATION);
}

#[rstest]
fn deterministic_initial_allocation_order() {
    let mut alloc = EntityAllocator::new();
    let a = alloc.alloc();
    let b = alloc.alloc();
    assert_eq!(a.slot(), 0);
    assert_eq!(b.slot(), 1);
    assert_eq!(a.generation(), 1);
    assert_eq!(b.generation(), 1);
}

#[rstest]
fn owner_token_rejects_identical_slot_generation_from_another_allocator() {
    let mut a = EntityAllocator::with_owner(11);
    let mut b = EntityAllocator::with_owner(12);
    let local = a.alloc();
    let foreign = b.alloc();
    assert_eq!(local.slot(), foreign.slot());
    assert_eq!(local.generation(), foreign.generation());
    assert_ne!(local, foreign);
    assert!(a.is_alive(local));
    assert!(!a.is_alive(foreign));
    assert_eq!(a.free(foreign), Err(AllocatorError::StaleEntity));
}

#[rstest]
fn reuse_bumps_generation() {
    let mut alloc = EntityAllocator::new();
    let a = alloc.alloc();
    alloc.free(a).expect("first free");
    assert!(!alloc.is_alive(a));
    let b = alloc.alloc();
    assert_eq!(a.slot(), b.slot());
    assert_eq!(b.generation(), 2);
}

#[rstest]
fn freed_but_not_reallocated_is_dead() {
    let mut alloc = EntityAllocator::new();
    let id = alloc.alloc();
    alloc.free(id).expect("free");
    assert!(!alloc.is_alive(id));
}

#[rstest]
fn double_free_is_non_destructive() {
    let mut alloc = EntityAllocator::new();
    let id = alloc.alloc();
    alloc.free(id).expect("first free");
    let before = alloc.counts();
    assert_eq!(alloc.free(id), Err(AllocatorError::StaleEntity));
    assert_eq!(alloc.counts(), before);
}

#[rstest]
fn released_reserved_does_not_resurrect() {
    let mut alloc = EntityAllocator::new();
    let reserved = alloc.reserve().expect("reserve");
    alloc.release_reserved(reserved).expect("release");
    assert!(!alloc.is_alive(reserved));
    let live = alloc.alloc();
    assert_eq!(live.slot(), reserved.slot());
    assert_eq!(live.generation(), reserved.generation() + 1);
    assert_ne!(live, reserved);
}

#[rstest]
fn reserved_is_not_alive() {
    let mut alloc = EntityAllocator::new();
    let id = alloc.reserve().expect("reserve");
    assert!(!alloc.is_alive(id));
    assert_eq!(alloc.counts().reserved, 1);
}

#[rstest]
fn slot_retired_rejects_live_operations() {
    let mut alloc = EntityAllocator::new();
    let id = alloc.alloc();
    alloc.set_generation_for_test(id, u32::MAX);
    let exhausted = EntityId::from_parts(id.slot(), u32::MAX);
    assert_eq!(
        alloc.free(exhausted),
        Err(AllocatorError::GenerationOverflow)
    );
    assert_eq!(alloc.free(exhausted), Err(AllocatorError::SlotRetired));
    assert_eq!(
        alloc.commit_reserved(exhausted),
        Err(AllocatorError::SlotRetired)
    );
}

#[rstest]
fn generation_overflow_retires_slot() {
    let mut alloc = EntityAllocator::new();
    let id = alloc.alloc();
    alloc.set_generation_for_test(id, u32::MAX);
    let exhausted = EntityId::from_parts(id.slot(), u32::MAX);
    assert_eq!(
        alloc.free(exhausted),
        Err(AllocatorError::GenerationOverflow)
    );
    assert!(!alloc.is_alive(exhausted));
    let counts = alloc.counts();
    assert_eq!(counts.retired, 1);
    assert_eq!(counts.free, 0);
}

#[rstest]
fn capacity_growth_preserves_live_handles() {
    let mut alloc = EntityAllocator::new();
    let mut live = Vec::new();
    for _ in 0..32 {
        live.push(alloc.alloc());
    }
    for id in &live {
        assert!(alloc.is_alive(*id));
    }
}

#[rstest]
fn free_on_reserved_handle_reports_stale_entity() {
    let mut alloc = EntityAllocator::new();
    let reserved = alloc.reserve().expect("reserve");
    assert_eq!(alloc.free(reserved), Err(AllocatorError::StaleEntity));
}

#[rstest]
fn commit_reserved_on_live_entity_reports_not_live() {
    let mut alloc = EntityAllocator::new();
    let live = alloc.alloc();
    assert_eq!(alloc.commit_reserved(live), Err(AllocatorError::NotLive));
}

#[rstest]
fn release_reserved_on_live_entity_reports_not_live() {
    let mut alloc = EntityAllocator::new();
    let live = alloc.alloc();
    assert_eq!(alloc.release_reserved(live), Err(AllocatorError::NotLive));
}

#[rstest]
fn commit_reserved_on_free_slot_reports_not_live() {
    let mut alloc = EntityAllocator::new();
    let live = alloc.alloc();
    alloc.free(live).expect("free");
    let next_gen = alloc.generation_for_slot(live.slot() as usize);
    let future = EntityId::from_parts(live.slot(), next_gen);
    assert_eq!(alloc.commit_reserved(future), Err(AllocatorError::NotLive));
}

#[rstest]
fn ensure_state_live_slot_with_mismatched_generation_is_stale() {
    let mut alloc = EntityAllocator::new();
    let live = alloc.alloc();
    alloc.set_generation_for_test(live, live.generation().wrapping_add(1));
    assert_eq!(
        alloc.ensure_state_for_test(live.slot() as usize, SlotState::Live, live.generation()),
        Err(AllocatorError::StaleEntity)
    );
}

#[rstest]
fn ensure_state_rejects_out_of_range_slots() {
    let mut alloc = EntityAllocator::new();
    let missing = EntityId::from_parts(9_999, 1);
    assert_eq!(alloc.free(missing), Err(AllocatorError::StaleEntity));
}

#[rstest]
fn default_allocator_matches_new() {
    assert_eq!(
        EntityAllocator::default().counts(),
        EntityAllocator::new().counts()
    );
}

#[rstest]
fn randomized_trace_matches_reference_model() {
    let mut alloc = EntityAllocator::new();
    let mut model = RefModel::new();
    let mut live = Vec::new();
    let mut seed = 0xC0FFEE_u32;

    for step in 0..2_000 {
        seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
        if live.is_empty() || seed % 3 != 2 {
            let id = alloc.alloc();
            assert!(alloc.is_alive(id));
            let model_id = model.alloc();
            assert_eq!(id.slot(), model_id.slot());
            assert_eq!(id.generation(), model_id.generation());
            live.push(id);
        } else {
            let index = (seed as usize) % live.len();
            let id = live[index];
            let alloc_result = alloc.free(id);
            let model_result = model.free(id);
            assert_eq!(alloc_result.is_ok(), model_result);
            live.swap_remove(index);
        }

        let counts = alloc.counts();
        assert_eq!(counts.live as usize, live.len());
        for id in &live {
            assert!(alloc.is_alive(*id));
            assert!(model.is_alive(*id));
        }
        let _ = step;
    }
}