Skip to main content

steel_core/world/player_spawn_finder/
chunk_load.rs

1use std::sync::Arc;
2use std::time::{Duration, Instant};
3
4use steel_utils::ChunkPos;
5
6use crate::chunk::chunk_request::ChunkRequestState;
7use crate::world::World;
8
9use super::ChunkRequestHandle;
10use super::candidates::CandidateSequence;
11
12/// Bounds the time one poll spends evaluating candidates.
13pub(super) struct PollBudget {
14    started: Instant,
15    limit: Duration,
16    evaluated: usize,
17}
18
19impl PollBudget {
20    pub(super) fn new(limit: Duration) -> Self {
21        Self {
22            started: Instant::now(),
23            limit,
24            evaluated: 0,
25        }
26    }
27
28    /// At least one candidate is evaluated per poll, so the search always moves forward.
29    pub(super) fn exhausted(&self) -> bool {
30        self.evaluated > 0 && self.started.elapsed() >= self.limit
31    }
32
33    pub(super) const fn record_evaluation(&mut self) {
34        self.evaluated += 1;
35    }
36}
37
38/// The chunk the search is currently reading.
39pub(super) struct ChunkLoad {
40    pub(super) chunk: ChunkPos,
41    /// `None` when the chunk is held by the prefetch, which keeps it loaded already.
42    request: Option<ChunkRequestHandle>,
43    /// Next sequence index to look at. Indices before it need no more work from this chunk.
44    pub(super) next_scan: i32,
45}
46
47#[derive(Clone, Copy)]
48pub(super) enum ChunkLoadState {
49    Ready,
50    Waiting,
51    Cancelled,
52}
53
54/// What [`ChunkLoad::scan`] does with a candidate.
55pub(super) enum Visit<T> {
56    /// The candidate needs no work, so it does not use up the budget.
57    Skip,
58    /// The candidate was evaluated.
59    Evaluated,
60    /// The candidate was evaluated and ends the scan with `T`.
61    Stop(T),
62}
63
64/// How [`ChunkLoad::scan`] ended.
65pub(super) enum ScanEnd<T> {
66    /// Every remaining candidate in the chunk was visited.
67    Finished,
68    /// The budget ran out; [`ChunkLoad::scan`] continues from the same place.
69    OutOfBudget,
70    /// A candidate ended the scan.
71    Stopped(T),
72}
73
74impl ChunkLoad {
75    pub(super) fn open(world: &Arc<World>, chunk: ChunkPos, next_scan: i32, held: bool) -> Self {
76        Self {
77            chunk,
78            request: (!held).then(|| world.request_spawn_candidate_chunk(chunk)),
79            next_scan,
80        }
81    }
82
83    pub(super) fn poll(&self, world: &Arc<World>) -> ChunkLoadState {
84        let Some(request) = &self.request else {
85            return if world.has_full_chunk(self.chunk) {
86                ChunkLoadState::Ready
87            } else {
88                ChunkLoadState::Waiting
89            };
90        };
91        match request.poll() {
92            ChunkRequestState::Cancelled => ChunkLoadState::Cancelled,
93            ChunkRequestState::Ready => ChunkLoadState::Ready,
94            ChunkRequestState::Pending { .. } => ChunkLoadState::Waiting,
95        }
96    }
97
98    /// Visits the candidates of this chunk in sequence order, from where the last scan stopped.
99    pub(super) fn scan<T>(
100        &mut self,
101        sequence: &CandidateSequence,
102        budget: &mut PollBudget,
103        mut visit: impl FnMut(i32, i32, i32) -> Visit<T>,
104    ) -> ScanEnd<T> {
105        while self.next_scan < sequence.count() {
106            if budget.exhausted() {
107                return ScanEnd::OutOfBudget;
108            }
109            let index = self.next_scan;
110            self.next_scan += 1;
111            if sequence.chunk(index) != self.chunk {
112                continue;
113            }
114            let (x, z) = sequence.column(index);
115            match visit(index, x, z) {
116                Visit::Skip => {}
117                Visit::Evaluated => budget.record_evaluation(),
118                Visit::Stop(value) => {
119                    budget.record_evaluation();
120                    return ScanEnd::Stopped(value);
121                }
122            }
123        }
124        ScanEnd::Finished
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use steel_utils::BlockPos;
131
132    use super::*;
133
134    #[test]
135    fn scan_resumes_without_skipping_or_repeating_candidates() {
136        for (suggestion, radius) in [
137            (BlockPos::new(0, 64, 0), 10),
138            (BlockPos::new(-1, 64, -1), 40),
139            (BlockPos::new(15, 64, 15), 1),
140            (BlockPos::new(0, 64, 0), 0),
141        ] {
142            let Ok(mut sequence) = CandidateSequence::new(suggestion, radius) else {
143                panic!("sequence should build");
144            };
145            for offset in [0, 1, sequence.count() - 1] {
146                sequence.offset = offset;
147                let expected: Vec<i32> = (0..sequence.count())
148                    .filter(|&index| sequence.chunk(index) == sequence.chunk(0))
149                    .collect();
150
151                // A zero budget ends every scan after one candidate, so this resumes at each.
152                let mut load = ChunkLoad {
153                    chunk: sequence.chunk(0),
154                    request: None,
155                    next_scan: 0,
156                };
157                let mut visited = Vec::new();
158                loop {
159                    let mut budget = PollBudget::new(Duration::ZERO);
160                    let end = load.scan(&sequence, &mut budget, |index, _, _| {
161                        visited.push(index);
162                        Visit::<()>::Evaluated
163                    });
164                    if matches!(end, ScanEnd::Finished) {
165                        break;
166                    }
167                }
168                assert_eq!(visited, expected, "radius {radius}, offset {offset}");
169            }
170        }
171    }
172}