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
111impl ChunkRequestInner {
112    fn tickets_committed(&self) -> bool {
113        self.lease
114            .submission_receipt
115            .is_none_or(|receipt| self.lease.chunk_map.is_ticket_receipt_committed(receipt))
116    }
117}
118
119/// Handle for a ticketed chunk request.
120///
121/// Dropping or cancelling the handle releases its tickets. The handle never
122/// creates chunk holders directly; lifecycle publication remains owned by the
123/// game-tick boundary.
124pub struct ChunkRequestHandle {
125    inner: Option<ChunkRequestInner>,
126}
127
128impl ChunkRequestHandle {
129    pub(crate) fn new(chunk_map: Arc<ChunkMap>, request: ChunkRequest) -> Self {
130        let positions = dedupe_positions(request.positions);
131        let ticket_level = ticket_level_for_status(request.status);
132        let lease = ChunkRequestLease::new(chunk_map, positions, ticket_level);
133
134        Self {
135            inner: Some(ChunkRequestInner {
136                lease,
137                status: request.status,
138                ticket_kind: request.ticket_kind,
139            }),
140        }
141    }
142
143    /// Returns the requested status, if this handle is still active.
144    #[must_use]
145    pub fn status(&self) -> Option<ChunkStatus> {
146        self.inner.as_ref().map(|inner| inner.status)
147    }
148
149    /// Returns the ticket kind, if this handle is still active.
150    #[must_use]
151    pub fn ticket_kind(&self) -> Option<ChunkTicketKind> {
152        self.inner.as_ref().map(|inner| inner.ticket_kind)
153    }
154
155    /// Returns requested positions after deduplication.
156    #[must_use]
157    pub fn positions(&self) -> &[ChunkPos] {
158        self.inner
159            .as_ref()
160            .map_or(&[], |inner| inner.lease.positions.as_ref())
161    }
162
163    /// Whether the request's tickets are committed, so its chunks stay loaded once they are
164    /// available. `false` once the request is cancelled.
165    #[must_use]
166    pub fn tickets_committed(&self) -> bool {
167        self.inner
168            .as_ref()
169            .is_some_and(ChunkRequestInner::tickets_committed)
170    }
171
172    /// Polls request readiness. The chunk-source phase owns chunk holder
173    /// creation and generation scheduling.
174    #[must_use]
175    pub fn poll(&self) -> ChunkRequestState {
176        let Some(inner) = &self.inner else {
177            return ChunkRequestState::Cancelled;
178        };
179        if inner.lease.positions.is_empty() {
180            return ChunkRequestState::Ready;
181        }
182        // Read before the scan: chunks seen ready afterwards stay held by the committed tickets.
183        let tickets_committed = inner.tickets_committed();
184        let mut ready = 0;
185        for &pos in &inner.lease.positions {
186            let Some(holder) = inner
187                .lease
188                .chunk_map
189                .chunks
190                .read_sync(&pos, |_, holder| Arc::clone(holder))
191            else {
192                continue;
193            };
194
195            if holder.try_chunk(inner.status).is_some() {
196                ready += 1;
197            }
198        }
199
200        if tickets_committed && ready == inner.lease.positions.len() {
201            ChunkRequestState::Ready
202        } else {
203            ChunkRequestState::Pending {
204                ready,
205                total: inner.lease.positions.len(),
206            }
207        }
208    }
209
210    /// Returns holders once every requested chunk is at the target status.
211    #[must_use]
212    pub fn ready_chunks(&self) -> Option<ReadyChunks> {
213        let inner = self.inner.as_ref()?;
214        if !inner.tickets_committed() {
215            return None;
216        }
217        let mut holders = Vec::with_capacity(inner.lease.positions.len());
218
219        for &pos in &inner.lease.positions {
220            let holder = inner
221                .lease
222                .chunk_map
223                .chunks
224                .read_sync(&pos, |_, holder| Arc::clone(holder))?;
225            {
226                let _chunk = holder.try_chunk(inner.status)?;
227            }
228            holders.push(holder);
229        }
230
231        Some(ReadyChunks {
232            status: inner.status,
233            holders,
234        })
235    }
236
237    /// Cancels the request and releases its tickets.
238    pub fn cancel(&mut self) {
239        drop(self.inner.take());
240    }
241}
242
243impl ChunkMap {
244    /// Adds tickets for a chunk request and returns a pollable handle.
245    ///
246    /// The returned handle owns the tickets. The chunk-source phase handles
247    /// holder creation and generation scheduling.
248    #[must_use]
249    pub fn request_chunks(self: &Arc<Self>, request: ChunkRequest) -> ChunkRequestHandle {
250        ChunkRequestHandle::new(Arc::clone(self), request)
251    }
252
253    /// Requests one chunk at `status`.
254    #[must_use]
255    pub fn request_chunk(
256        self: &Arc<Self>,
257        pos: ChunkPos,
258        status: ChunkStatus,
259        ticket_kind: ChunkTicketKind,
260    ) -> ChunkRequestHandle {
261        self.request_chunks(ChunkRequest {
262            status,
263            positions: vec![pos],
264            ticket_kind,
265        })
266    }
267
268    /// Requests a square of chunks centered on `center`.
269    #[must_use]
270    pub fn request_square(
271        self: &Arc<Self>,
272        center: ChunkPos,
273        radius: u8,
274        status: ChunkStatus,
275        ticket_kind: ChunkTicketKind,
276    ) -> ChunkRequestHandle {
277        let radius = i32::from(radius);
278        let diameter = radius * 2 + 1;
279        let capacity = (diameter * diameter) as usize;
280        let mut positions = Vec::with_capacity(capacity);
281
282        for dz in -radius..=radius {
283            for dx in -radius..=radius {
284                positions.push(ChunkPos::new(center.0.x + dx, center.0.y + dz));
285            }
286        }
287
288        self.request_chunks(ChunkRequest {
289            status,
290            positions,
291            ticket_kind,
292        })
293    }
294}
295
296fn dedupe_positions(positions: Vec<ChunkPos>) -> Box<[ChunkPos]> {
297    let mut seen = FxHashSet::default();
298    let mut deduped = Vec::with_capacity(positions.len());
299    for pos in positions {
300        if seen.insert(pos) {
301            deduped.push(pos);
302        }
303    }
304    deduped.into_boxed_slice()
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::test_support::fresh_test_world;
311    use std::{thread, time::Duration};
312
313    fn drive_request_until_ready(chunk_map: &Arc<ChunkMap>, request: &ChunkRequestHandle) {
314        for _ in 0..10_000 {
315            chunk_map.advance_scheduling();
316            if request.poll() == ChunkRequestState::Ready {
317                return;
318            }
319            thread::sleep(Duration::from_millis(1));
320        }
321        panic!("chunk request did not become ready");
322    }
323
324    #[test]
325    fn dedupe_positions_preserves_first_occurrence_order() {
326        let positions = dedupe_positions(vec![
327            ChunkPos::new(1, 2),
328            ChunkPos::new(3, 4),
329            ChunkPos::new(1, 2),
330        ]);
331        assert_eq!(&*positions, &[ChunkPos::new(1, 2), ChunkPos::new(3, 4)]);
332    }
333
334    #[test]
335    fn ready_chunk_still_waits_for_its_ticket_receipt_to_commit() {
336        let world_fixture = fresh_test_world("chunk_request_receipt");
337        let world = &world_fixture.world;
338        let pos = ChunkPos::new(4, -7);
339        let first =
340            world
341                .chunk_map
342                .request_chunk(pos, ChunkStatus::Empty, ChunkTicketKind::Command);
343        drive_request_until_ready(&world.chunk_map, &first);
344
345        let second =
346            world
347                .chunk_map
348                .request_chunk(pos, ChunkStatus::Empty, ChunkTicketKind::Command);
349
350        assert_eq!(
351            second.poll(),
352            ChunkRequestState::Pending { ready: 1, total: 1 }
353        );
354        assert!(second.ready_chunks().is_none());
355        assert!(!second.tickets_committed());
356
357        drive_request_until_ready(&world.chunk_map, &second);
358        assert!(second.ready_chunks().is_some());
359        assert!(second.tickets_committed());
360    }
361
362    #[test]
363    fn a_cancelled_request_has_no_committed_tickets() {
364        let world_fixture = fresh_test_world("chunk_request_cancel");
365        let world = &world_fixture.world;
366        let mut request = world.chunk_map.request_chunk(
367            ChunkPos::new(1, 1),
368            ChunkStatus::Empty,
369            ChunkTicketKind::Command,
370        );
371        drive_request_until_ready(&world.chunk_map, &request);
372        assert!(request.tickets_committed());
373
374        request.cancel();
375        assert!(!request.tickets_committed());
376    }
377}