Skip to main content

steel_core/server/
run_loop.rs

1use super::tick_overload::TickOverloadGuard;
2use super::world_tick_workers::{WorldTickWorkerError, WorldTickWorkers};
3use super::{
4    Arc, CCommandSuggestions, CHUNK_SENDING_TPS, COMMAND_DATA_AUTOSAVE_INTERVAL,
5    COMMAND_REQUESTS_PER_TICK, COMMAND_RESUMPTIONS_PER_TICK, CancellationToken, ChunkPos,
6    ChunkSender, CommandExecutionContext, CommandExecutionOwner, CommandRequest,
7    CommandResultCallback, CommandSender, CommandSource, Duration, EncodedChunk,
8    ExecutionCommandSource, ExecutionStop, GameTickTaskGuard, GlobalPlayerData, Instant, JoinSet,
9    MenuRemovalStatus, NetworkConnection, PendingCommandExecutionQueue, PersistentPlayerData,
10    Player, SEND_PLAYER_INFO_INTERVAL, SLOW_CHUNK_TICK_THRESHOLD, Server, StringReader,
11    SuggestionError, Suggestions, TAB_LIST_UPDATE_INTERVAL, TabListTickStats, ThreadPool, World,
12    command_suggestions_packet, sleep, spawn_blocking,
13};
14
15use steel_registry::vanilla_custom_stats;
16use steel_utils::threading::{available_worker_threads, worker_threads_for_available};
17use steel_utils::translations;
18
19impl Server {
20    pub(super) fn advance_server_tick(&self) -> (u64, bool) {
21        let (tick_count, runs_normally) = {
22            let mut tick_manager = self.tick_rate_manager.write();
23            tick_manager.tick();
24            let runs_normally = tick_manager.runs_normally();
25            tick_manager.increment_tick_count();
26            (tick_manager.tick_count, runs_normally)
27        };
28        self.server_tick_changed.notify_waiters();
29        (tick_count, runs_normally)
30    }
31
32    /// Runs gameplay packets, game ticks, and chunk sending.
33    pub async fn run(self: Arc<Self>, cancel_token: CancellationToken) {
34        self.packet_processor.open_after_tick();
35        let packet_worker_count =
36            worker_threads_for_available(self.config.packet_workers, available_worker_threads());
37        let mut packet_handles = Vec::with_capacity(packet_worker_count);
38        for worker_id in 0..packet_worker_count {
39            let s = Arc::clone(&self);
40            let t = cancel_token.clone();
41            packet_handles.push(tokio::spawn(async move {
42                if let Err(error) = spawn_blocking(move || s.packet_processor.run(&s)).await {
43                    log::error!("Gameplay packet worker {worker_id} failed: {error}");
44                    t.cancel();
45                }
46            }));
47        }
48        let packet_supervisor_cancel = cancel_token.clone();
49        let packet_workers = async move {
50            for handle in packet_handles {
51                if let Err(error) = handle.await {
52                    log::error!("Gameplay packet supervisor failed: {error}");
53                    packet_supervisor_cancel.cancel();
54                }
55            }
56        };
57        let game_handle = {
58            let s = Arc::clone(&self);
59            let t = cancel_token.clone();
60            let task_guard = GameTickTaskGuard::new(Arc::clone(&self), cancel_token.clone());
61            tokio::spawn(async move {
62                let _task_guard = task_guard;
63                s.run_game_tick(t).await;
64            })
65        };
66        let chunk_send_handle = {
67            let s = Arc::clone(&self);
68            let t = cancel_token.clone();
69            tokio::spawn(async move { s.run_chunk_sending_tick(t).await })
70        };
71        let ((), game_result, chunk_send_result) =
72            tokio::join!(packet_workers, game_handle, chunk_send_handle);
73        for (task, result) in [
74            ("Game tick", game_result),
75            ("Chunk sending tick", chunk_send_result),
76        ] {
77            if let Err(error) = result {
78                log::error!("{task} task failed: {error}");
79            }
80        }
81    }
82
83    /// Saves everything and tears the worlds down, in the order shutdown requires.
84    ///
85    /// # Panics
86    ///
87    /// Panics if a player's menus cannot be removed, which means packets are still being
88    /// processed. Callers must stop packet processing before calling this; the standalone
89    /// server does so by closing its task tracker and awaiting the drain.
90    pub async fn save_and_shutdown(&self) {
91        if let Err(error) = self.flush_known_players().await {
92            log::error!("Failed to flush known player cache during shutdown: {error}");
93        }
94
95        let players = self.get_players();
96        for player in &players {
97            // Claim the removal first so a later tick cannot run the ordinary disconnect
98            // path for the same player and award the leave-game stat twice.
99            let _ = self.reserve_player_disconnect(player);
100            player.disconnect(translations::MULTIPLAYER_DISCONNECT_SERVER_SHUTDOWN.msg());
101            assert_eq!(
102                player.remove_all_menus(),
103                MenuRemovalStatus::Complete,
104                "shutdown menu removal must run after packet processing stops"
105            );
106        }
107
108        for world in self.worlds.values() {
109            world.chunk_map.stop_generation_refill_loop();
110            world.chunk_map.task_tracker.close();
111            world.chunk_map.task_tracker.wait().await;
112        }
113
114        let mut players_to_save = Vec::new();
115        for player in players {
116            let domain = player.get_world().domain().to_owned();
117            player.award_custom_stat(&vanilla_custom_stats::LEAVE_GAME);
118            let data = PersistentPlayerData::from_player(&player);
119            player.store_ender_pearls_with_player();
120            players_to_save.push((player, domain, data));
121        }
122
123        self.damage_history.clear();
124        log::info!("Saving world data...");
125        let command_data = self.save_command_data().await;
126        match command_data.scoreboards {
127            Ok(saved) => log::info!("Saved {saved} domain scoreboards"),
128            Err(error) => log::error!("Failed to save domain scoreboards: {error}"),
129        }
130        match command_data.storage {
131            Ok(saved) => log::info!("Saved {saved} domain command storages"),
132            Err(error) => log::error!("Failed to save domain command storage: {error}"),
133        }
134        let mut total_saved = 0;
135        for world in self.worlds.values() {
136            world.cleanup(&mut total_saved).await;
137        }
138        log::info!("Saved {total_saved} chunks");
139
140        log::info!("Saving player data...");
141        let mut saved = 0;
142        for (player, domain, data) in players_to_save {
143            let uuid = player.gameprofile.id();
144            match self
145                .player_data_storage
146                .save_domain_data(&domain, uuid, &data)
147                .await
148            {
149                Ok(()) => {
150                    saved += 1;
151                }
152                Err(e) => {
153                    log::error!("Failed to save player {uuid} domain data during shutdown: {e}");
154                }
155            }
156            if let Err(e) = self
157                .player_data_storage
158                .save_global(
159                    uuid,
160                    &GlobalPlayerData {
161                        last_active_domain: domain,
162                    },
163                )
164                .await
165            {
166                log::error!("Failed to save player {uuid} global data during shutdown: {e}");
167            }
168        }
169        log::info!("Saved {saved} players");
170    }
171
172    /// The main game tick loop (20 TPS, governed by tick rate manager).
173    #[expect(
174        clippy::too_many_lines,
175        reason = "the ordered tick phases and their shutdown joins remain easier to audit together"
176    )]
177    async fn run_game_tick(self: Arc<Self>, cancel_token: CancellationToken) {
178        let world_tick_workers = match WorldTickWorkers::spawn(self.worlds.values()) {
179            Ok(workers) => workers,
180            Err(error) => {
181                log::error!("Failed to start world tick workers: {error}");
182                cancel_token.cancel();
183                return;
184            }
185        };
186        let mut next_tick_time = Instant::now();
187        let mut overload_guard = TickOverloadGuard::new();
188        let mut next_command_data_autosave = Instant::now() + COMMAND_DATA_AUTOSAVE_INTERVAL;
189        let mut player_info_ticks = 0_u64;
190        let mut pending_command_executions = PendingCommandExecutionQueue::<CommandSource>::new();
191        let mut player_disconnect_saves = JoinSet::new();
192        let mut command_data_autosaves = JoinSet::new();
193
194        loop {
195            if cancel_token.is_cancelled() {
196                break;
197            }
198
199            let (nanoseconds_per_tick, should_sprint_this_tick) = {
200                let mut tick_manager = self.tick_rate_manager.write();
201                let nanoseconds_per_tick = tick_manager.nanoseconds_per_tick;
202                let (should_sprint, sprint_report) = tick_manager.check_should_sprint_this_tick();
203                drop(tick_manager);
204
205                if let Some(report) = sprint_report {
206                    self.broadcast_sprint_report(&report);
207                    self.broadcast_ticking_state();
208                }
209
210                (nanoseconds_per_tick, should_sprint)
211            };
212
213            if should_sprint_this_tick {
214                next_tick_time = Instant::now();
215                overload_guard.restart_report_gap(next_tick_time);
216            } else {
217                let now = Instant::now();
218                if now < next_tick_time {
219                    tokio::select! {
220                        () = cancel_token.cancelled() => break,
221                        () = sleep(next_tick_time - now) => {}
222                    }
223                } else {
224                    overload_guard.skip_backlog_if_overloaded(
225                        now,
226                        &mut next_tick_time,
227                        nanoseconds_per_tick,
228                    );
229                }
230                next_tick_time += Duration::from_nanos(nanoseconds_per_tick);
231            }
232
233            if cancel_token.is_cancelled() {
234                break;
235            }
236
237            let tick_start = Instant::now();
238            self.packet_processor.close_for_tick().await;
239            self.start_player_disconnect_saves(&mut player_disconnect_saves);
240
241            let (tick_count, runs_normally) = self.advance_server_tick();
242
243            self.tick_pending_command_executions(&mut pending_command_executions);
244            self.tick_command_requests(&mut pending_command_executions);
245            if let Err(error) = self
246                .tick_worlds_game(&world_tick_workers, tick_count, runs_normally)
247                .await
248            {
249                log::error!("World game tick failed: {error}");
250                cancel_token.cancel();
251                break;
252            }
253            player_info_ticks += 1;
254            if player_info_ticks > SEND_PLAYER_INFO_INTERVAL {
255                let _span = tracing::trace_span!("broadcast_latency").entered();
256                self.broadcast_player_latency_updates();
257                player_info_ticks = 0;
258            }
259            self.tick_jobs(tick_count, runs_normally);
260            self.process_player_joins();
261
262            {
263                let server = Arc::clone(&self);
264                let _ =
265                    spawn_blocking(move || server.process_world_changes(tick_count, runs_normally))
266                        .await;
267            }
268
269            self.process_domain_switches();
270
271            self.tick_command_data_autosave(
272                &mut next_command_data_autosave,
273                &mut command_data_autosaves,
274            );
275
276            let tab_list_tick_stats = self.record_tick_and_capture_tab_stats(
277                tick_count,
278                tick_start.elapsed().as_nanos() as u64,
279            );
280
281            if let Some(tick_stats) = tab_list_tick_stats {
282                self.broadcast_tab_list(tick_stats);
283            }
284
285            if should_sprint_this_tick {
286                let mut tick_manager = self.tick_rate_manager.write();
287                tick_manager.end_tick_work();
288            }
289
290            self.packet_processor.open_after_tick();
291            if should_sprint_this_tick || Instant::now() >= next_tick_time {
292                self.packet_processor.wait_for_overload_progress().await;
293            }
294        }
295
296        self.jobs.cancel_all();
297        pending_command_executions.cancel_all();
298        self.command_requests.clear();
299        self.packet_processor.stop();
300        self.start_player_disconnect_saves(&mut player_disconnect_saves);
301        self.pending_player_disconnects.clear();
302        while let Some(result) = player_disconnect_saves.join_next().await {
303            if let Err(error) = result {
304                log::error!("Player disconnect save task failed during shutdown: {error}");
305            }
306        }
307        while let Some(result) = command_data_autosaves.join_next().await {
308            if let Err(error) = result {
309                log::error!("Command data autosave task failed during shutdown: {error}");
310            }
311        }
312    }
313
314    fn record_tick_and_capture_tab_stats(
315        &self,
316        tick_count: u64,
317        tick_duration_nanos: u64,
318    ) -> Option<TabListTickStats> {
319        let mut tick_manager = self.tick_rate_manager.write();
320        tick_manager.record_tick_time(tick_duration_nanos);
321        tick_count
322            .is_multiple_of(TAB_LIST_UPDATE_INTERVAL)
323            .then(|| TabListTickStats::capture(&tick_manager))
324    }
325
326    async fn autosave_command_data(&self) {
327        tracing::debug!("Command data autosave started");
328        let results = self.save_command_data().await;
329        match results.scoreboards {
330            Ok(saved) => tracing::debug!(saved, "Domain scoreboard autosave completed"),
331            Err(error) => tracing::error!(%error, "Domain scoreboard autosave failed"),
332        }
333        match results.storage {
334            Ok(saved) => tracing::debug!(saved, "Domain command-storage autosave completed"),
335            Err(error) => tracing::error!(%error, "Domain command-storage autosave failed"),
336        }
337    }
338
339    fn tick_command_data_autosave(
340        self: &Arc<Self>,
341        next_autosave: &mut Instant,
342        saves: &mut JoinSet<()>,
343    ) {
344        while let Some(result) = saves.try_join_next() {
345            if let Err(error) = result {
346                log::error!("Command data autosave task failed: {error}");
347            }
348        }
349        if Instant::now() < *next_autosave {
350            return;
351        }
352        if saves.is_empty() {
353            let server = Arc::clone(self);
354            saves.spawn(async move {
355                server.autosave_command_data().await;
356            });
357        } else {
358            tracing::warn!("Skipping command data autosave while the previous save runs");
359        }
360        *next_autosave = Instant::now() + COMMAND_DATA_AUTOSAVE_INTERVAL;
361    }
362
363    fn tick_pending_command_executions(
364        &self,
365        pending: &mut PendingCommandExecutionQueue<CommandSource>,
366    ) {
367        let stats = pending.tick(COMMAND_RESUMPTIONS_PER_TICK, |owner| owner.is_current(self));
368        if stats.polled == COMMAND_RESUMPTIONS_PER_TICK && stats.pending > 0 {
369            tracing::debug!(
370                polled = stats.polled,
371                finished = stats.finished,
372                pending = stats.pending,
373                "Command resumption tick reached per-tick processing limit"
374            );
375        }
376    }
377
378    fn tick_command_requests(
379        self: &Arc<Self>,
380        pending: &mut PendingCommandExecutionQueue<CommandSource>,
381    ) {
382        let mut handled = 0;
383        for _ in 0..COMMAND_REQUESTS_PER_TICK {
384            let Some(request) = self
385                .command_requests
386                .pop_front_runnable(|owner| !pending.blocks(owner.key()))
387            else {
388                break;
389            };
390            handled += 1;
391
392            match request {
393                CommandRequest::Execute { owner, command } => {
394                    if !owner.is_current(self) {
395                        continue;
396                    }
397                    self.execute_command_request(pending, owner, &command);
398                }
399                CommandRequest::Suggestions {
400                    owner,
401                    transaction_id,
402                    input,
403                } => {
404                    if !owner.is_current(self) {
405                        continue;
406                    }
407                    let Some(player) = owner.sender().get_player() else {
408                        tracing::error!("command suggestion request has a non-player owner");
409                        continue;
410                    };
411                    self.send_command_suggestions(player, transaction_id, &input);
412                }
413            }
414        }
415
416        if handled == COMMAND_REQUESTS_PER_TICK {
417            tracing::debug!(handled, "Command request tick reached its processing limit");
418        }
419    }
420
421    fn execute_command_request(
422        self: &Arc<Self>,
423        pending: &mut PendingCommandExecutionQueue<CommandSource>,
424        owner: CommandExecutionOwner,
425        command: &str,
426    ) {
427        let source = CommandSource::new(owner.sender().clone(), Arc::clone(self));
428        let command = command.strip_prefix('/').unwrap_or(command);
429        let chain = {
430            let dispatcher = self.command_dispatcher.read();
431            let parse = dispatcher.parse(command, source.clone());
432            dispatcher.context_chain(parse)
433        };
434        let chain = match chain {
435            Ok(chain) => chain,
436            Err(error) => {
437                source.handle_error(&error, false);
438                return;
439            }
440        };
441
442        let mut execution = CommandExecutionContext::for_source(&source);
443        execution.queue_initial_command(chain, source, CommandResultCallback::empty());
444        if execution.run() == ExecutionStop::Suspended && !pending.push_suspended(owner, execution)
445        {
446            tracing::error!("suspended command execution could not be retained");
447        }
448    }
449
450    fn send_command_suggestions(
451        self: &Arc<Self>,
452        player: &Arc<Player>,
453        transaction_id: i32,
454        input: &str,
455    ) {
456        let suggestions =
457            self.build_command_suggestions(CommandSender::Player(Arc::clone(player)), input);
458        match suggestions {
459            Ok(suggestions) => {
460                player.send_packet(command_suggestions_packet(transaction_id, &suggestions));
461            }
462            Err(error) => {
463                tracing::warn!(%error, "failed to build command suggestions");
464                player.send_packet(CCommandSuggestions::new(transaction_id, 0, 0, Vec::new()));
465            }
466        }
467    }
468
469    pub(super) fn build_command_suggestions(
470        self: &Arc<Self>,
471        sender: CommandSender,
472        input: &str,
473    ) -> Result<Suggestions, SuggestionError> {
474        let source = CommandSource::new(sender, Arc::clone(self));
475        let mut reader = StringReader::new(input);
476        if reader.peek() == Some('/') {
477            reader.skip();
478        }
479        let dispatcher = self.command_dispatcher.read();
480        let parse = dispatcher.parse_reader(reader, source);
481        dispatcher.completion_suggestions(&parse)
482    }
483
484    /// Chunk sending tick loop — encodes and sends chunks to players independently.
485    async fn run_chunk_sending_tick(self: Arc<Self>, cancel_token: CancellationToken) {
486        let nanos_per_tick = 1_000_000_000 / CHUNK_SENDING_TPS;
487        let mut next_tick_time = Instant::now();
488
489        loop {
490            if cancel_token.is_cancelled() {
491                break;
492            }
493
494            let now = Instant::now();
495            if now < next_tick_time {
496                tokio::select! {
497                    () = cancel_token.cancelled() => break,
498                    () = sleep(next_tick_time - now) => {}
499                }
500            }
501            next_tick_time += Duration::from_nanos(nanos_per_tick);
502
503            if cancel_token.is_cancelled() {
504                break;
505            }
506
507            let server = Arc::clone(&self);
508            let _ = spawn_blocking(move || {
509                server.tick_chunk_sending();
510            })
511            .await;
512        }
513    }
514
515    /// Executes one chunk sending tick across all worlds and players.
516    ///
517    /// A per-world per-tick encode cache is used so overlapping view areas
518    /// don't re-encode the same chunk within a single tick.
519    fn tick_chunk_sending(&self) {
520        let tick_start = Instant::now();
521        for world in self.worlds.values() {
522            let mut encode_cache = rustc_hash::FxHashMap::default();
523            world.players.iter_players(|_uuid, player| {
524                Self::send_chunks_for_player(
525                    player,
526                    world,
527                    &mut encode_cache,
528                    self.chunk_encoding_pool.as_ref(),
529                );
530                true
531            });
532        }
533
534        let elapsed = tick_start.elapsed();
535        if elapsed >= SLOW_CHUNK_TICK_THRESHOLD {
536            tracing::warn!(?elapsed, "Chunk sending tick slow");
537        }
538    }
539
540    /// Three-phase chunk send for a single player: prepare (lock briefly),
541    /// encode (no lock), commit (lock briefly + generation check).
542    fn send_chunks_for_player(
543        player: &Arc<Player>,
544        world: &Arc<World>,
545        encode_cache: &mut rustc_hash::FxHashMap<ChunkPos, EncodedChunk>,
546        encoding_pool: &ThreadPool,
547    ) {
548        let chunk_pos = *player.last_chunk_pos.lock();
549        let connection = &player.connection;
550
551        // Phase 1: prepare (brief lock)
552        let prepared = {
553            let mut sender = player.chunk_sender().lock();
554            sender.prepare_batch(world, chunk_pos, &player.chunk_send_epoch)
555        };
556
557        let Some(batch) = prepared else {
558            return;
559        };
560
561        // Phase 2: encode (no lock held — uses per-tick local cache)
562        let compression = connection.compression();
563        let encoded = ChunkSender::encode_batch(&batch, encode_cache, compression, encoding_pool);
564
565        // Phase 3: commit while holding the tracking view that world detachment invalidates.
566        // This makes membership validation, packet commit, and tracker refresh one side of
567        // the same synchronization boundary.
568        let tracking_view = player.last_tracking_view.lock();
569        let Some(view) = *tracking_view else {
570            return;
571        };
572        if !world.contains_player(player) {
573            return;
574        }
575        let sent_chunks = {
576            let mut sender = player.chunk_sender().lock();
577            sender.commit_batch(&batch, encoded, connection, &player.chunk_send_epoch)
578        };
579
580        if sent_chunks.is_empty() {
581            return;
582        }
583
584        let sent_chunks = player.chunk_sender().lock().sent_chunks_snapshot();
585        world
586            .entity_tracker()
587            .update_player(player, &view, |chunk| sent_chunks.contains(&chunk));
588    }
589
590    #[tracing::instrument(level = "trace", skip(self, workers), name = "tick_worlds")]
591    pub(super) async fn tick_worlds_game(
592        &self,
593        workers: &WorldTickWorkers,
594        tick_count: u64,
595        runs_normally: bool,
596    ) -> Result<(), WorldTickWorkerError> {
597        if runs_normally {
598            self.worlds.advance_domain_game_times();
599        }
600        self.damage_history.expire();
601        let all_timings = workers.tick_all(tick_count, runs_normally).await?;
602        for (i, timings) in all_timings.iter().enumerate() {
603            if timings.elapsed < SLOW_CHUNK_TICK_THRESHOLD {
604                continue;
605            }
606            let cm = &timings.chunk_map;
607            let scheduling = &cm.scheduling;
608            tracing::warn!(
609                world = i,
610                elapsed = ?timings.elapsed,
611                tick_count,
612                entity_tick = ?timings.entity_tick,
613                ticket_updates = ?scheduling.ticket_updates,
614                block_entity_unloads = ?scheduling.block_entity_unloads,
615                readiness_demotions = ?scheduling.readiness_demotions,
616                lifecycle_commit = ?scheduling.lifecycle_commit,
617                readiness_reconcile = ?scheduling.readiness_reconcile,
618                post_process_generation = ?scheduling.post_process_generation,
619                post_process_chunk_count = scheduling.post_process_chunk_count,
620                post_process_position_count = scheduling.post_process_position_count,
621                readiness_candidate_count = scheduling.readiness_candidate_count,
622                ticking_snapshot_rebuild = ?scheduling.ticking_snapshot_rebuild,
623                rebuilt_ticking_chunk_count = scheduling.rebuilt_ticking_chunk_count,
624                schedule_generation = ?scheduling.schedule_generation,
625                scheduled_count = scheduling.scheduled_count,
626                run_generation = ?scheduling.run_generation,
627                process_unloads = ?scheduling.process_unloads,
628                broadcast_changes = ?cm.broadcast_changes,
629                collect_tickable = ?cm.collect_tickable,
630                tick_chunks = ?cm.tick_chunks,
631                tick_block_entities = ?cm.tick_block_entities,
632                tickable_count = cm.tickable_count,
633                total_chunks = cm.total_chunks,
634                lookup_cache_holder_hits = cm.lookup_cache.holder_hits,
635                lookup_cache_missing_hits = cm.lookup_cache.missing_hits,
636                lookup_cache_scc_lookups = cm.lookup_cache.scc_lookups,
637                lookup_cache_foreign_map_bypasses = cm.lookup_cache.foreign_map_bypasses,
638                lookup_cache_evictions = cm.lookup_cache.evictions,
639                "Game tick slow"
640            );
641        }
642        Ok(())
643    }
644
645    pub(super) fn tick_jobs(self: &Arc<Self>, tick_count: u64, runs_normally: bool) {
646        let stats = self
647            .jobs
648            .tick(Arc::downgrade(self), tick_count, runs_normally);
649        if stats.polled > 0 && stats.pending > 0 && tick_count.is_multiple_of(100) {
650            tracing::debug!(
651                polled = stats.polled,
652                finished = stats.finished,
653                pending = stats.pending,
654                "Server jobs pending"
655            );
656        }
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use std::sync::Arc;
663
664    use super::Server;
665
666    use crate::{
667        player::ResetReason,
668        test_support::{TestPlayerBuilder, fresh_test_world, insert_ready_full_chunk},
669    };
670    use rustc_hash::FxHashMap;
671    use steel_utils::ChunkPos;
672
673    #[test]
674    fn chunk_send_commit_rechecks_live_world_membership() {
675        let world_fixture = fresh_test_world("chunk_send_membership_revalidation");
676        let world = &world_fixture.world;
677        let center = ChunkPos::new(0, 0);
678        insert_ready_full_chunk(world, center);
679        let player = TestPlayerBuilder::new(Arc::clone(world), "ChunkTester", 1).build();
680        assert!(world.add_player(Arc::clone(&player), ResetReason::InitialJoin));
681        assert!(world.players.remove_player_sync(&player).is_some());
682
683        let encoding_pool = rayon::ThreadPoolBuilder::new().num_threads(1).build();
684        let Ok(encoding_pool) = encoding_pool else {
685            panic!("test chunk encoding pool should initialize");
686        };
687        let mut encode_cache = FxHashMap::default();
688        Server::send_chunks_for_player(&player, world, &mut encode_cache, &encoding_pool);
689
690        let sender = player.chunk_sender().lock();
691        assert!(sender.pending_chunks.contains(&center));
692        assert!(!sender.is_chunk_sent(center));
693        assert_eq!(sender.unacknowledged_batch_count_for_test(), 0);
694        drop(sender);
695
696        assert!(world.players.insert(Arc::clone(&player)));
697        world.remove_player_for_world_change(&player);
698    }
699}