Skip to main content

steel_core/chunk/
chunk_request.rs

1//! Ticket-owned chunk availability requests.
2
3use std::sync::Arc;
4
5use rustc_hash::FxHashSet;
6use steel_utils::ChunkPos;
7
8use crate::chunk::{
9    chunk_holder::ChunkHolder,
10    chunk_map::ChunkMap,
11    chunk_scheduler::ChunkTicketRevision,
12    chunk_ticket_manager::{ChunkTicket, ticket_level_for_status},
13    status::ChunkStatus,
14};
15
16/// Why a chunk request is holding tickets.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ChunkTicketKind {
19    /// Player-visible chunk loading.
20    Player,
21    /// Initial chunks around a joining player's spawn.
22    PlayerSpawn,
23    /// Candidate chunks loaded while searching for a valid spawn position.
24    SpawnSearch,
25    /// Candidate chunks loaded by structure location queries.
26    StructureLocate,
27    /// Chunks loaded by startup pregeneration.
28    Pregen,
29    /// Generic command-owned chunk request.
30    Command,
31    /// Chunks loaded while preparing a portal destination.
32    Portal,
33}
34
35/// Request for a set of chunks at a minimum generation status.
36pub struct ChunkRequest {
37    /// Minimum chunk status required before the request is ready.
38    pub status: ChunkStatus,
39    /// Chunk positions requested.
40    pub positions: Vec<ChunkPos>,
41    /// Ticket owner category.
42    pub ticket_kind: ChunkTicketKind,
43}
44
45/// Poll result for a [`ChunkRequestHandle`].
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ChunkRequestState {
48    /// The request has outstanding chunks.
49    Pending {
50        /// Number of requested chunks already at the target status.
51        ready: usize,
52        /// Number of requested chunks after deduplication.
53        total: usize,
54    },
55    /// Every requested chunk is available at the target status.
56    Ready,
57    /// The request was cancelled and no longer owns tickets.
58    Cancelled,
59}
60
61/// Chunks passed to request continuations once a request is ready.
62pub struct ReadyChunks {
63    /// Status all holders have reached.
64    pub status: ChunkStatus,
65    /// Holders for the requested positions.
66    pub holders: Vec<Arc<ChunkHolder>>,
67}
68
69struct ChunkRequestInner {
70    chunk_map: Arc<ChunkMap>,
71    positions: Box<[ChunkPos]>,
72    status: ChunkStatus,
73    ticket_kind: ChunkTicketKind,
74    ticket: ChunkTicket,
75    submission_revision: Option<ChunkTicketRevision>,
76}
77
78/// Handle for a ticketed chunk request.
79///
80/// Dropping or cancelling the handle releases its tickets. The handle never
81/// creates chunk holders directly; lifecycle publication remains owned by the
82/// game-tick boundary.
83pub struct ChunkRequestHandle {
84    inner: Option<ChunkRequestInner>,
85}
86
87impl ChunkRequestHandle {
88    pub(crate) fn new(chunk_map: Arc<ChunkMap>, request: ChunkRequest) -> Self {
89        let ticket = ChunkTicket::loading(ticket_level_for_status(request.status));
90        Self::new_with_ticket(chunk_map, request, ticket)
91    }
92
93    fn new_with_ticket(
94        chunk_map: Arc<ChunkMap>,
95        request: ChunkRequest,
96        ticket: ChunkTicket,
97    ) -> Self {
98        let positions = dedupe_positions(request.positions);
99        let submission_revision = chunk_map.add_chunk_tickets(&positions, ticket);
100
101        Self {
102            inner: Some(ChunkRequestInner {
103                chunk_map,
104                positions,
105                status: request.status,
106                ticket_kind: request.ticket_kind,
107                ticket,
108                submission_revision,
109            }),
110        }
111    }
112
113    /// Returns the requested status, if this handle is still active.
114    #[must_use]
115    pub fn status(&self) -> Option<ChunkStatus> {
116        self.inner.as_ref().map(|inner| inner.status)
117    }
118
119    /// Returns the ticket kind, if this handle is still active.
120    #[must_use]
121    pub fn ticket_kind(&self) -> Option<ChunkTicketKind> {
122        self.inner.as_ref().map(|inner| inner.ticket_kind)
123    }
124
125    /// Returns requested positions after deduplication.
126    #[must_use]
127    pub fn positions(&self) -> &[ChunkPos] {
128        self.inner
129            .as_ref()
130            .map_or(&[], |inner| inner.positions.as_ref())
131    }
132
133    /// Polls request readiness. Chunk holder creation and generation scheduling
134    /// are owned by the chunk scheduling epochs.
135    #[must_use]
136    pub fn poll(&self) -> ChunkRequestState {
137        let Some(inner) = &self.inner else {
138            return ChunkRequestState::Cancelled;
139        };
140        if inner.positions.is_empty() {
141            return ChunkRequestState::Ready;
142        }
143        let ticket_revision_committed = inner
144            .submission_revision
145            .is_none_or(|revision| inner.chunk_map.is_ticket_revision_committed(revision));
146
147        let mut ready = 0;
148        for &pos in &inner.positions {
149            let Some(holder) = inner
150                .chunk_map
151                .chunks
152                .read_sync(&pos, |_, holder| holder.clone())
153            else {
154                continue;
155            };
156
157            if holder.try_chunk(inner.status).is_some() {
158                ready += 1;
159            }
160        }
161
162        if ticket_revision_committed && ready == inner.positions.len() {
163            ChunkRequestState::Ready
164        } else {
165            ChunkRequestState::Pending {
166                ready,
167                total: inner.positions.len(),
168            }
169        }
170    }
171
172    /// Returns holders once every requested chunk is at the target status.
173    #[must_use]
174    pub fn ready_chunks(&self) -> Option<ReadyChunks> {
175        let inner = self.inner.as_ref()?;
176        if inner
177            .submission_revision
178            .is_some_and(|revision| !inner.chunk_map.is_ticket_revision_committed(revision))
179        {
180            return None;
181        }
182        let mut holders = Vec::with_capacity(inner.positions.len());
183
184        for &pos in &inner.positions {
185            let holder = inner
186                .chunk_map
187                .chunks
188                .read_sync(&pos, |_, holder| holder.clone())?;
189            {
190                let _chunk = holder.try_chunk(inner.status)?;
191            }
192            holders.push(holder);
193        }
194
195        Some(ReadyChunks {
196            status: inner.status,
197            holders,
198        })
199    }
200
201    /// Cancels the request and releases its tickets.
202    pub fn cancel(&mut self) {
203        self.release_tickets();
204    }
205
206    fn release_tickets(&mut self) {
207        let Some(inner) = self.inner.take() else {
208            return;
209        };
210
211        let _ = inner
212            .chunk_map
213            .remove_chunk_tickets(&inner.positions, inner.ticket);
214    }
215}
216
217impl Drop for ChunkRequestHandle {
218    fn drop(&mut self) {
219        self.release_tickets();
220    }
221}
222
223impl ChunkMap {
224    /// Adds tickets for a chunk request and returns a pollable handle.
225    ///
226    /// The returned handle owns the tickets. Holder creation and generation
227    /// scheduling are handled by chunk scheduling epochs.
228    #[must_use]
229    pub fn request_chunks(self: &Arc<Self>, request: ChunkRequest) -> ChunkRequestHandle {
230        ChunkRequestHandle::new(self.clone(), request)
231    }
232
233    /// Requests one chunk at `status`.
234    #[must_use]
235    pub fn request_chunk(
236        self: &Arc<Self>,
237        pos: ChunkPos,
238        status: ChunkStatus,
239        ticket_kind: ChunkTicketKind,
240    ) -> ChunkRequestHandle {
241        self.request_chunks(ChunkRequest {
242            status,
243            positions: vec![pos],
244            ticket_kind,
245        })
246    }
247
248    /// Requests a square of chunks centered on `center`.
249    #[must_use]
250    pub fn request_square(
251        self: &Arc<Self>,
252        center: ChunkPos,
253        radius: u8,
254        status: ChunkStatus,
255        ticket_kind: ChunkTicketKind,
256    ) -> ChunkRequestHandle {
257        let radius = i32::from(radius);
258        let diameter = radius * 2 + 1;
259        let capacity = (diameter * diameter) as usize;
260        let mut positions = Vec::with_capacity(capacity);
261
262        for dz in -radius..=radius {
263            for dx in -radius..=radius {
264                positions.push(ChunkPos::new(center.0.x + dx, center.0.y + dz));
265            }
266        }
267
268        self.request_chunks(ChunkRequest {
269            status,
270            positions,
271            ticket_kind,
272        })
273    }
274}
275
276fn dedupe_positions(positions: Vec<ChunkPos>) -> Box<[ChunkPos]> {
277    let mut seen = FxHashSet::default();
278    let mut deduped = Vec::with_capacity(positions.len());
279    for pos in positions {
280        if seen.insert(pos) {
281            deduped.push(pos);
282        }
283    }
284    deduped.into_boxed_slice()
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::test_support::fresh_test_world;
291    use std::{thread, time::Duration};
292
293    fn drive_request_until_ready(chunk_map: &Arc<ChunkMap>, request: &ChunkRequestHandle) {
294        for _ in 0..10_000 {
295            chunk_map.advance_scheduling();
296            if request.poll() == ChunkRequestState::Ready {
297                return;
298            }
299            thread::sleep(Duration::from_millis(1));
300        }
301        panic!("chunk request did not become ready");
302    }
303
304    #[test]
305    fn dedupe_positions_preserves_first_occurrence_order() {
306        let positions = dedupe_positions(vec![
307            ChunkPos::new(1, 2),
308            ChunkPos::new(3, 4),
309            ChunkPos::new(1, 2),
310        ]);
311        assert_eq!(&*positions, &[ChunkPos::new(1, 2), ChunkPos::new(3, 4)]);
312    }
313
314    #[test]
315    fn ready_chunk_still_waits_for_its_ticket_revision_to_commit() {
316        let world = fresh_test_world("chunk_request_revision");
317        let pos = ChunkPos::new(4, -7);
318        let first =
319            world
320                .chunk_map
321                .request_chunk(pos, ChunkStatus::Empty, ChunkTicketKind::Command);
322        drive_request_until_ready(&world.chunk_map, &first);
323
324        let second =
325            world
326                .chunk_map
327                .request_chunk(pos, ChunkStatus::Empty, ChunkTicketKind::Command);
328
329        assert_eq!(
330            second.poll(),
331            ChunkRequestState::Pending { ready: 1, total: 1 }
332        );
333        assert!(second.ready_chunks().is_none());
334
335        drive_request_until_ready(&world.chunk_map, &second);
336        assert!(second.ready_chunks().is_some());
337    }
338}