diff --git a/crates/horizon-client/examples/world_options_fixture.rs b/crates/horizon-client/examples/world_options_fixture.rs new file mode 100644 index 0000000000..5bbf9f1c7f --- /dev/null +++ b/crates/horizon-client/examples/world_options_fixture.rs @@ -0,0 +1,40 @@ +//! Packet payload oracle companion for tools/test_world_options_parity.py. +use horizon_protocol::{buf::Reader, packets::play::*}; +use std::{error::Error, fmt::Write}; + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + let mut output = String::new(); + for line in std::fs::read_to_string(&args[1])?.lines() { + let fields: Vec<&str> = line.split('\t').collect(); + let entries = if fields.len() == 3 { + vec![GameRuleEntry { + name: format!("minecraft:{}", fields[1]), + value: fields[2].into(), + }] + } else { + vec![] + }; + let bytes = match fields[0] { + "difficulty" => Serverbound::ChangeDifficulty(ChangeDifficultyRequest { + difficulty: fields[1].parse()?, + }) + .encode(), + "lock" => Serverbound::LockDifficulty(LockDifficulty { + locked: fields[1].parse()?, + }) + .encode(), + "set" => Serverbound::SetGameRule(SetGameRule { entries }).encode(), + "values" => Clientbound::GameRuleValues(GameRuleValues { entries }).encode(), + _ => return Err("unknown case".into()), + }; + let mut reader = Reader::new(&bytes); + reader.varint()?; + for byte in reader.rest() { + write!(output, "{byte:02x}")?; + } + output.push('\n'); + } + std::fs::write(&args[2], output)?; + Ok(()) +} diff --git a/crates/horizon-client/src/front.rs b/crates/horizon-client/src/front.rs index 5b0a2c5ab1..0b4eebd622 100644 --- a/crates/horizon-client/src/front.rs +++ b/crates/horizon-client/src/front.rs @@ -1173,6 +1173,7 @@ pub mod pause { quit_label: &str, opened: Instant, friends: &FriendsView, + remote: bool, ) { let (w, h) = (ui.w, ui.h); let p = t::ease_out_quint(opened.elapsed().as_secs_f32() / t::move_()); @@ -1207,11 +1208,11 @@ pub mod pause { Kind::Primary, true, ); - let lw = ui.button_width("Open to LAN", true); + let lw = ui.button_width("World Options", true); ui.button( act(9), [x + bw + 8.0, y, lw, CONTROL_LARGE], - "Open to LAN", + "World Options", Kind::Secondary, true, ); @@ -1235,7 +1236,11 @@ pub mod pause { x, y, "Leave", - "Saves the world, then returns to the title.", + if remote { + "Disconnects from the server, then returns to the title." + } else { + "Saves the world, then returns to the title." + }, cw - t::ROW_LABEL - 220.0, ); let qw = ui.button_width(quit_label, false); @@ -1363,6 +1368,7 @@ pub mod pause { world: &str, quit_label: &str, friends: &FriendsView, + remote: bool, ) -> Option { let mut ui = Ui::dry(atlas, batch, (x, y)); draw( @@ -1371,6 +1377,7 @@ pub mod pause { quit_label, Instant::now() - std::time::Duration::from_secs(5), friends, + remote, ); let hit = ui.hit((x, y))?; let nav = id("pause.nav", 0); @@ -1391,6 +1398,47 @@ mod tests { use super::*; use horizon_world::nbt::Tag; + #[test] + fn pause_world_options_and_disconnect_are_clickable_on_servers() { + let atlas = + HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap(); + let friends = FriendsView::default(); + for remote in [false, true] { + let quit = if remote { + "Disconnect" + } else { + "Save and quit" + }; + for action in [9u8, 2] { + let mut batch = HudBatch::new([1280, 720]); + let (rect, density) = { + let mut ui = Ui::dry(&atlas, &mut batch, (0.0, 0.0)); + pause::draw( + &mut ui, + "World", + quit, + Instant::now() - std::time::Duration::from_secs(5), + &friends, + remote, + ); + let hit = ui + .hits + .iter() + .find(|h| h.id == id("pause", u64::from(action))) + .expect("pause action available"); + (hit.rect, ui.density) + }; + let k = density / batch.scale; + let x = (rect[0] + rect[2]) / 2.0 * k; + let y = (rect[1] + rect[3]) / 2.0 * k; + assert_eq!( + pause::click(&atlas, &mut batch, x, y, "World", quit, &friends, remote), + Some(action) + ); + } + } + } + fn world(saves: &std::path::Path, folder: &str, last_played: i64) { crate::save::write_dat( &saves.join(folder).join("level.dat"), diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index ec4ac6cee3..cfb9bb411f 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -540,6 +540,70 @@ impl Game<'_> { } } S::ChatCommand(c) => self.guest_command(host, conn, &c.command), + S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => { + if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) { + self.ui.difficulty = if self.level.hardcore { + 3 + } else { + p.difficulty as u8 + }; + self.sync_rules(); + if self.ui.difficulty == 0 { + self.mobs.retain(|m| !m.hostile()); + } + host.hub + .broadcast(&C::ChangeDifficulty(play::ChangeDifficulty { + difficulty: i32::from(self.ui.difficulty), + locked: self.level.difficulty_locked, + })); + } + } + S::LockDifficulty(p) if host.hub.guests[gi].permission >= 2 => { + self.level.difficulty_locked = p.locked; + host.hub + .broadcast(&C::ChangeDifficulty(play::ChangeDifficulty { + difficulty: i32::from(self.ui.difficulty), + locked: self.level.difficulty_locked, + })); + } + S::ClientCommand(p) if p.action == 2 && host.hub.guests[gi].permission >= 2 => { + host.hub.send_to( + conn, + &C::GameRuleValues(play::GameRuleValues { + entries: horizon_client::gamerules::RULES + .iter() + .map(|r| play::GameRuleEntry { + name: format!("minecraft:{}", r.name), + value: self.ui.rules.get(r.name).unwrap_or(r.default).to_string(), + }) + .collect(), + }), + ); + } + S::SetGameRule(p) if host.hub.guests[gi].permission >= 2 => { + for entry in p.entries { + if self.ui.rules.set_string(&entry.name, &entry.value) { + let message = horizon_client::text::Text::tr( + "commands.gamerule.set", + &[&entry.name, &entry.value], + ); + let content = + horizon_client::lan::data::component_nbt(&message.component()); + for g in &host.hub.guests { + if g.permission >= 2 { + g.send(&C::SystemChat(play::SystemChat { + content: content.clone(), + overlay: false, + })); + } + } + if self.options.dedicated.is_none() && self.allow_commands() { + self.system_message(message); + } + } + } + self.sync_rules(); + } S::ChangeGameMode(p) if host.hub.guests[gi].permission >= 2 => { if let Some(mode) = usize::try_from(p.mode) .ok() diff --git a/crates/horizon-client/src/game/lan_screen.rs b/crates/horizon-client/src/game/lan_screen.rs index 7c4533a165..6be6fc7fae 100644 --- a/crates/horizon-client/src/game/lan_screen.rs +++ b/crates/horizon-client/src/game/lan_screen.rs @@ -4,6 +4,7 @@ //! difficulty, LAN on/off with its port, command access for guests and //! forcing the game mode. Its difficulty lock asks first (PopupScreen), and //! Restrictions opens RestrictionsScreen. +//! Parity reference: https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/WorldOptionsScreen use super::{ Game, Screen, game_rules_screen::{Exit, RulesScreen}, @@ -16,9 +17,12 @@ use horizon_client::{ text::{Text, named_color}, ui::{self, EditBox}, }; +use horizon_protocol::packets::play::{self, Serverbound as S}; use winit::{event::KeyEvent, keyboard::PhysicalKey}; pub(super) struct WorldOptions { + remote: bool, + gamemaster: bool, default_mode: i32, personal_mode: i32, cheats: bool, @@ -109,6 +113,36 @@ fn labelled(key: &str, value: Text) -> Text { } impl WorldOptions { + /// Vanilla 26.3 WorldOptionsScreen.generalOptions: guests see difficulty, + /// game rules and restrictions; game modes, commands and LAN are host-only. + fn visible_buttons(&self, w: f32) -> Vec<(Button, f32, f32, f32)> { + if !self.remote { + return Self::buttons(w, self.top()); + } + let x = Self::left(w); + let top = self.top(); + vec![ + (Button::Difficulty, x, top + 29.0, 130.0), + (Button::Lock, x + 130.0, top + 29.0, 20.0), + (Button::GameRules, x + 158.0, top + 29.0, 150.0), + (Button::Restrictions, x, top + 53.0, 150.0), + ] + } + fn content_height(&self) -> f32 { + if self.remote { 81.0 } else { CONTENT } + } + fn scroll_limit(&self, h: f32) -> f32 { + (self.content_height() - (h - HEADER - FOOTER)).max(0.0) + } + fn server_difficulty(&mut self, difficulty: u8, locked: bool) { + // A server update is the new baseline, not a local edit. In particular, + // the reply to Apply may arrive after the screen has already reopened. + self.difficulty = difficulty; + self.difficulty_locked = locked; + self.locked = locked || self.hardcore; + self.initial.difficulty = self.difficulty; + self.initial.locked = self.locked; + } fn parsed_port(&self) -> Option { if self.port.value.is_empty() { return Some(self.hint); @@ -176,16 +210,13 @@ impl WorldOptions { /// scroll area. fn hit(&self, (x, y): (f32, f32), (w, h): (f32, f32)) -> Option