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