mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Fix multiplayer pause menu and World Options permissions
This commit is contained in:
1 parent
99d5ebea1a
commit
aeaccb0413
12 files changed
+599
-49
No files matched your search
@@ -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<dyn Error>> {
|
||||
let args: Vec<String> = 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(())
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<u16> {
|
||||
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<Button> {
|
||||
let inside = y >= HEADER && y < h - FOOTER;
|
||||
Self::buttons(w, self.top())
|
||||
self.visible_buttons(w)
|
||||
.into_iter()
|
||||
.filter(|_| inside)
|
||||
.chain(Self::footer(w, h))
|
||||
.find(|(b, bx, by, bw)| self.active(*b) && ui::hovered((x, y), *bx, *by, *bw, 20.0))
|
||||
.map(|b| b.0)
|
||||
}
|
||||
fn max_scroll(h: f32) -> f32 {
|
||||
(CONTENT - (h - HEADER - FOOTER)).max(0.0)
|
||||
}
|
||||
fn label(&self, b: Button) -> Text {
|
||||
let mode = |m: i32| {
|
||||
Text::translate(
|
||||
@@ -230,6 +261,17 @@ impl WorldOptions {
|
||||
}
|
||||
/// WorldOptionsScreen.updateButton and the update*Button methods.
|
||||
fn active(&self, b: Button) -> bool {
|
||||
if self.remote {
|
||||
return match b {
|
||||
Button::Difficulty | Button::Lock => {
|
||||
self.gamemaster && !self.hardcore && !self.difficulty_locked
|
||||
}
|
||||
Button::GameRules => self.gamemaster,
|
||||
Button::Restrictions | Button::Cancel => true,
|
||||
Button::Apply => self.gamemaster && self.changed(),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
match b {
|
||||
Button::DefaultMode | Button::PersonalMode => self.cheats && !self.hardcore,
|
||||
// An owner's commands come with Allow Commands.
|
||||
@@ -405,6 +447,34 @@ impl Restrictions {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn remote_world_options_permission(&mut self, permission: bool) {
|
||||
if let Some(o) = self.world_options.as_mut() {
|
||||
let lost = o.gamemaster && !permission;
|
||||
o.gamemaster = permission;
|
||||
if lost && o.remote {
|
||||
self.close_world_options();
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn remote_world_options_difficulty(&mut self, difficulty: u8, locked: bool) {
|
||||
if let Some(o) = self.world_options.as_mut() {
|
||||
o.server_difficulty(difficulty, locked);
|
||||
}
|
||||
}
|
||||
pub(super) fn remote_world_options_rules(&mut self, entries: Vec<play::GameRuleEntry>) {
|
||||
let Some(o) = self
|
||||
.world_options
|
||||
.as_mut()
|
||||
.filter(|o| o.view == View::GameRules)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut rules = horizon_client::gamerules::GameRules::default();
|
||||
for entry in entries {
|
||||
rules.set_string(&entry.name, &entry.value);
|
||||
}
|
||||
o.rules = Some(RulesScreen::new(&rules));
|
||||
}
|
||||
pub(super) fn open_world_options(&mut self) {
|
||||
let hint = horizon_client::lan::available_port();
|
||||
let mut port = EditBox::new(0.0, 0.0, 150.0, 20.0);
|
||||
@@ -421,6 +491,8 @@ impl Game<'_> {
|
||||
port.set_value(&p.to_string());
|
||||
}
|
||||
let mut options = WorldOptions {
|
||||
remote: self.external_server(),
|
||||
gamemaster: self.ui.registries.permission >= 2,
|
||||
default_mode: state.default_mode,
|
||||
personal_mode: state.personal_mode,
|
||||
cheats: state.cheats,
|
||||
@@ -463,7 +535,10 @@ impl Game<'_> {
|
||||
pub(super) fn world_options_escape(&mut self) {
|
||||
match self.world_options.as_mut() {
|
||||
Some(o) if o.view == View::GameRules => {
|
||||
if let Some(exit) = o.rules.as_mut().and_then(RulesScreen::escape) {
|
||||
if o.rules.is_none() {
|
||||
// A server may be slow to answer REQUEST_GAMERULE_VALUES.
|
||||
o.view = View::Options;
|
||||
} else if let Some(exit) = o.rules.as_mut().and_then(RulesScreen::escape) {
|
||||
self.close_game_rules(exit);
|
||||
}
|
||||
}
|
||||
@@ -483,6 +558,20 @@ impl Game<'_> {
|
||||
let Exit::Apply(changes) = exit else {
|
||||
return;
|
||||
};
|
||||
if self.remote.is_some() {
|
||||
if self.ui.registries.permission >= 2 && !changes.is_empty() {
|
||||
self.send(S::SetGameRule(play::SetGameRule {
|
||||
entries: changes
|
||||
.into_iter()
|
||||
.map(|(name, value)| play::GameRuleEntry {
|
||||
name: format!("minecraft:{name}"),
|
||||
value: value.to_string(),
|
||||
})
|
||||
.collect(),
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !self.allow_commands() || changes.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -508,6 +597,16 @@ impl Game<'_> {
|
||||
View::GameRules => {
|
||||
if let Some(rules) = o.rules.as_mut() {
|
||||
rules.draw(batch, &self.atlas, self.cursor);
|
||||
} else {
|
||||
ui::menu_background(
|
||||
batch,
|
||||
&self.atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
batch.width,
|
||||
batch.height,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -590,7 +689,7 @@ impl Game<'_> {
|
||||
fn draw_options_view(&self, o: &mut WorldOptions, batch: &mut HudBatch, cursor: (f32, f32)) {
|
||||
let atlas = &self.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
o.scroll = o.scroll.clamp(0.0, WorldOptions::max_scroll(h));
|
||||
o.scroll = o.scroll.clamp(0.0, o.scroll_limit(h));
|
||||
// extractMenuBackground: the in-world background, the list
|
||||
// background under the content, then the separators over it.
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
@@ -612,6 +711,9 @@ impl Game<'_> {
|
||||
("options.worldOptions.general.title", GENERAL_TOP),
|
||||
("options.worldOptions.multiplayer.title", MULTIPLAYER_TOP),
|
||||
] {
|
||||
if o.remote && key == "options.worldOptions.multiplayer.title" {
|
||||
continue;
|
||||
}
|
||||
// A FocusableTextWidget padded by 4, centred over both columns.
|
||||
let t = Text::translate(key, &[]).underlined().bold();
|
||||
let tw = atlas.font.width_text(&t);
|
||||
@@ -619,7 +721,7 @@ impl Game<'_> {
|
||||
batch.text_styled(atlas, &t, tx, top + y + 4.0, u32::MAX, true);
|
||||
}
|
||||
let inside = cursor.1 >= HEADER && cursor.1 < h - FOOTER;
|
||||
for (b, bx, by, bw) in WorldOptions::buttons(w, top) {
|
||||
for (b, bx, by, bw) in o.visible_buttons(w) {
|
||||
let active = o.active(b);
|
||||
let hover = active && inside && ui::hovered(cursor, bx, by, bw, 20.0);
|
||||
if b == Button::Lock {
|
||||
@@ -637,32 +739,34 @@ impl Game<'_> {
|
||||
}
|
||||
// The port box beside the LAN button: editable only with LAN on,
|
||||
// its hint the port to use (setPortError colours the text).
|
||||
o.port.x = x + 158.0;
|
||||
o.port.y = top + PORT_TOP;
|
||||
o.port.focused = o.lan && o.port.focused;
|
||||
o.port.text_color = if !o.lan {
|
||||
rgba(0x70, 0x70, 0x70, 255)
|
||||
} else if o.parsed_port().is_some() {
|
||||
rgba(0xE0, 0xE0, 0xE0, 255)
|
||||
} else {
|
||||
rgba(0xDF, 0x50, 0x50, 255)
|
||||
};
|
||||
o.port.draw(batch, atlas, None);
|
||||
if o.port.value.is_empty() && !o.port.focused {
|
||||
let hint = if o.lan {
|
||||
Text::literal(o.hint.to_string())
|
||||
if !o.remote {
|
||||
o.port.x = x + 158.0;
|
||||
o.port.y = top + PORT_TOP;
|
||||
o.port.focused = o.lan && o.port.focused;
|
||||
o.port.text_color = if !o.lan {
|
||||
rgba(0x70, 0x70, 0x70, 255)
|
||||
} else if o.parsed_port().is_some() {
|
||||
rgba(0xE0, 0xE0, 0xE0, 255)
|
||||
} else {
|
||||
Text::translate("lanServer.port", &[])
|
||||
rgba(0xDF, 0x50, 0x50, 255)
|
||||
};
|
||||
// EditBox.DEFAULT_HINT_STYLE: dark gray.
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&hint,
|
||||
o.port.text_x(),
|
||||
o.port.text_y(),
|
||||
rgba(0x55, 0x55, 0x55, 255),
|
||||
true,
|
||||
);
|
||||
o.port.draw(batch, atlas, None);
|
||||
if o.port.value.is_empty() && !o.port.focused {
|
||||
let hint = if o.lan {
|
||||
Text::literal(o.hint.to_string())
|
||||
} else {
|
||||
Text::translate("lanServer.port", &[])
|
||||
};
|
||||
// EditBox.DEFAULT_HINT_STYLE: dark gray.
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&hint,
|
||||
o.port.text_x(),
|
||||
o.port.text_y(),
|
||||
rgba(0x55, 0x55, 0x55, 255),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
|
||||
ui::scrollbar(
|
||||
@@ -671,7 +775,7 @@ impl Game<'_> {
|
||||
(w / 2.0).floor() + 166.0,
|
||||
HEADER,
|
||||
h - FOOTER,
|
||||
CONTENT,
|
||||
o.content_height(),
|
||||
o.scroll.trunc(),
|
||||
);
|
||||
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
|
||||
@@ -687,7 +791,7 @@ impl Game<'_> {
|
||||
let h = self.gui_size().1;
|
||||
match self.world_options.as_mut() {
|
||||
Some(o) if o.view == View::Options => {
|
||||
o.scroll = (o.scroll - delta * 10.0).clamp(0.0, WorldOptions::max_scroll(h));
|
||||
o.scroll = (o.scroll - delta * 10.0).clamp(0.0, o.scroll_limit(h));
|
||||
}
|
||||
Some(o) => {
|
||||
if let Some(rules) = o.rules.as_mut() {
|
||||
@@ -760,7 +864,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
|
||||
o.port.focused = o.lan
|
||||
o.port.focused = !o.remote
|
||||
&& o.lan
|
||||
&& inside
|
||||
&& ui::hovered(
|
||||
self.cursor,
|
||||
@@ -789,7 +894,12 @@ impl Game<'_> {
|
||||
Some(Button::Lock) => o.locked = !o.locked,
|
||||
Some(Button::Restrictions) => o.view = View::Restrictions,
|
||||
Some(Button::GameRules) => {
|
||||
o.rules = Some(RulesScreen::new(&self.ui.rules));
|
||||
if self.remote.is_some() {
|
||||
o.rules = None;
|
||||
self.send(S::ClientCommand(play::ClientCommand { action: 2 }));
|
||||
} else {
|
||||
o.rules = Some(RulesScreen::new(&self.ui.rules));
|
||||
}
|
||||
o.view = View::GameRules;
|
||||
}
|
||||
Some(Button::Lan) => {
|
||||
@@ -915,6 +1025,19 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn apply_world_options(&mut self, before: &Snapshot, wanted: &Snapshot) {
|
||||
if self.external_server() {
|
||||
if self.ui.registries.permission >= 2 {
|
||||
if wanted.difficulty != before.difficulty {
|
||||
self.send(S::ChangeDifficulty(play::ChangeDifficultyRequest {
|
||||
difficulty: i32::from(wanted.difficulty),
|
||||
}));
|
||||
}
|
||||
if wanted.locked && !before.locked {
|
||||
self.send(S::LockDifficulty(play::LockDifficulty { locked: true }));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(server) = &self.integrated {
|
||||
// The server applies them; the window tells what came of
|
||||
// Open to LAN (ShareToLanScreen's messages).
|
||||
@@ -985,6 +1108,8 @@ mod tests {
|
||||
|
||||
fn options(cheats: bool, hardcore: bool) -> WorldOptions {
|
||||
let mut o = WorldOptions {
|
||||
remote: false,
|
||||
gamemaster: cheats,
|
||||
default_mode: 1,
|
||||
personal_mode: 1,
|
||||
cheats,
|
||||
@@ -1038,8 +1163,8 @@ mod tests {
|
||||
assert_eq!(at(Button::Force), (217.0, 223.0, 150.0));
|
||||
assert_eq!(at(Button::Apply), (59.0, 213.0, 150.0));
|
||||
// 218 pixels of content over 174 between the separators.
|
||||
assert_eq!(WorldOptions::max_scroll(240.0), 44.0);
|
||||
assert_eq!(WorldOptions::max_scroll(400.0), 0.0);
|
||||
assert_eq!(options(true, false).scroll_limit(240.0), 44.0);
|
||||
assert_eq!(options(true, false).scroll_limit(400.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1083,4 +1208,74 @@ mod tests {
|
||||
let o = options(true, true);
|
||||
assert!(o.locked && !o.active(Button::Lock));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_options_follow_vanilla_guest_layout_and_permissions() {
|
||||
let mut o = options(false, false);
|
||||
o.remote = true;
|
||||
// Vanilla omits host-only rows instead of disabling the whole screen.
|
||||
let rows = o.visible_buttons(427.0);
|
||||
assert_eq!(
|
||||
rows.iter().map(|b| b.0).collect::<Vec<_>>(),
|
||||
vec![
|
||||
Button::Difficulty,
|
||||
Button::Lock,
|
||||
Button::GameRules,
|
||||
Button::Restrictions,
|
||||
]
|
||||
);
|
||||
assert_eq!(rows[0], (Button::Difficulty, 59.0, 62.0, 130.0));
|
||||
assert_eq!(rows[2], (Button::GameRules, 217.0, 62.0, 150.0));
|
||||
assert_eq!(rows[3], (Button::Restrictions, 59.0, 86.0, 150.0));
|
||||
assert_eq!(o.scroll_limit(240.0), 0.0);
|
||||
// A non-operator cannot activate the difficulty or rule controls.
|
||||
assert_eq!(o.hit((100.0, 70.0), (427.0, 240.0)), None);
|
||||
assert_eq!(o.hit((240.0, 70.0), (427.0, 240.0)), None);
|
||||
assert_eq!(
|
||||
o.hit((100.0, 90.0), (427.0, 240.0)),
|
||||
Some(Button::Restrictions)
|
||||
);
|
||||
o.gamemaster = true;
|
||||
assert_eq!(
|
||||
o.hit((100.0, 70.0), (427.0, 240.0)),
|
||||
Some(Button::Difficulty)
|
||||
);
|
||||
assert_eq!(
|
||||
o.hit((240.0, 70.0), (427.0, 240.0)),
|
||||
Some(Button::GameRules)
|
||||
);
|
||||
o.difficulty_locked = true;
|
||||
assert_eq!(o.hit((100.0, 70.0), (427.0, 240.0)), None);
|
||||
// Remote hardcore does not hide an operator's Game Rules screen.
|
||||
o.hardcore = true;
|
||||
assert!(o.active(Button::GameRules));
|
||||
assert!(!o.active(Button::Lock));
|
||||
for b in [
|
||||
Button::DefaultMode,
|
||||
Button::PersonalMode,
|
||||
Button::Cheats,
|
||||
Button::Lan,
|
||||
Button::CommandAccess,
|
||||
Button::Force,
|
||||
] {
|
||||
assert!(!o.active(b));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delayed_server_reply_does_not_mark_reopened_options_as_edited() {
|
||||
let mut o = options(false, false);
|
||||
o.remote = true;
|
||||
o.gamemaster = true;
|
||||
// Reopened while the previous Apply's reply was still in flight.
|
||||
o.server_difficulty(3, false);
|
||||
assert_eq!(o.difficulty, 3);
|
||||
assert!(!o.active(Button::Apply));
|
||||
o.difficulty = 0;
|
||||
assert!(o.active(Button::Apply));
|
||||
o.server_difficulty(3, true);
|
||||
assert!(!o.active(Button::Apply));
|
||||
assert!(o.locked);
|
||||
assert!(!o.active(Button::Difficulty));
|
||||
}
|
||||
}
|
||||
@@ -248,6 +248,7 @@ impl Game<'_> {
|
||||
progress: 0.0,
|
||||
total: 0,
|
||||
};
|
||||
self.ui.registries.permission = 0;
|
||||
self.screen = Screen::Playing;
|
||||
self.title = None;
|
||||
self.dim = Dim::Overworld;
|
||||
@@ -882,6 +883,7 @@ impl Game<'_> {
|
||||
self.debug_charts.ping.log(delay);
|
||||
}
|
||||
C::Login(p) => {
|
||||
self.level.hardcore = p.hardcore;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.online_mode = p.online_mode;
|
||||
r.entity_id = p.player_id;
|
||||
@@ -1199,13 +1201,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::EntityEvent(p) => {
|
||||
// EntityEvent.PERMISSION_LEVEL_ALL..OWNERS (24..=28) for this
|
||||
// player: LocalPlayer.setPermissionLevel, which the commands
|
||||
// the chat offers and checks follow.
|
||||
// LocalPlayer.setPermissions also refreshes an open World Options screen.
|
||||
if (24..=28).contains(&p.event)
|
||||
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
{
|
||||
self.ui.registries.permission = (p.event - 24) as u8;
|
||||
self.remote_world_options_permission(p.event >= 26);
|
||||
}
|
||||
if p.event == 1
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
@@ -1229,6 +1230,12 @@ impl Game<'_> {
|
||||
self.potions.effects.list.retain(|e| e.id != id);
|
||||
}
|
||||
}
|
||||
C::ChangeDifficulty(p) => {
|
||||
self.ui.difficulty = p.difficulty.clamp(0, 3) as u8;
|
||||
self.level.difficulty_locked = p.locked;
|
||||
self.remote_world_options_difficulty(self.ui.difficulty, p.locked);
|
||||
}
|
||||
C::GameRuleValues(p) => self.remote_world_options_rules(p.entries),
|
||||
C::SetHealth(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.health = (p.health, p.food, p.saturation);
|
||||
|
||||
@@ -8,7 +8,8 @@ use horizon_client::{
|
||||
};
|
||||
|
||||
/// PauseScreen buttons: (x, y, width, label key, action id, active).
|
||||
fn pause_buttons(w: f32, h: f32) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
|
||||
/// https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/PauseScreen
|
||||
fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
|
||||
// GridLayout of two 98px columns (padding 4), aligned at (0.5, 0.25).
|
||||
let (grid_w, grid_h) = (212.0, 166.0);
|
||||
let x0 = ((w - grid_w) * 0.5).floor();
|
||||
@@ -26,9 +27,21 @@ fn pause_buttons(w: f32, h: f32) -> Vec<(f32, f32, f32, &'static str, u8, bool)>
|
||||
9,
|
||||
true,
|
||||
),
|
||||
(x0 + 4.0, y0 + 146.0, 204.0, "menu.returnToMenu", 2, true),
|
||||
(
|
||||
x0 + 4.0,
|
||||
y0 + 146.0,
|
||||
204.0,
|
||||
if remote {
|
||||
"menu.disconnect"
|
||||
} else {
|
||||
"menu.returnToMenu"
|
||||
},
|
||||
2,
|
||||
true,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The icon row: report bugs, feedback, friends, player reporting.
|
||||
fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
|
||||
let x0 = ((w - 212.0) * 0.5).floor() + 60.0;
|
||||
@@ -52,6 +65,10 @@ fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
|
||||
]
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// Singleplayer also has a connection, backed by the integrated server.
|
||||
pub(super) fn external_server(&self) -> bool {
|
||||
self.remote.is_some() && self.integrated.is_none()
|
||||
}
|
||||
/// Whether the pause menu is drawn in Horizon's style (over a blurred world).
|
||||
pub(super) fn horizon_pause(&self) -> bool {
|
||||
self.screen == Screen::Paused
|
||||
@@ -63,7 +80,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
return (server.world_name.clone(), "Save and quit");
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
if self.external_server() {
|
||||
("Multiplayer".to_owned(), "Disconnect")
|
||||
} else {
|
||||
(self.level.name.clone(), "Save and quit")
|
||||
@@ -76,7 +93,14 @@ impl Game<'_> {
|
||||
self.tick_friends();
|
||||
let friends = self.friends_view();
|
||||
let mut ui = horizon_client::ui_theme::Ui::new(&self.atlas, batch, self.cursor);
|
||||
horizon_client::front::pause::draw(&mut ui, &world, quit, opened, &friends);
|
||||
horizon_client::front::pause::draw(
|
||||
&mut ui,
|
||||
&world,
|
||||
quit,
|
||||
opened,
|
||||
&friends,
|
||||
self.external_server(),
|
||||
);
|
||||
ui.finish();
|
||||
return;
|
||||
}
|
||||
@@ -93,7 +117,7 @@ impl Game<'_> {
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
for (x, y, bw, key, _, active) in pause_buttons(w, h) {
|
||||
for (x, y, bw, key, _, active) in pause_buttons(w, h, self.external_server()) {
|
||||
let hover = active && ui::hovered(self.cursor, x, y, bw, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
@@ -139,9 +163,10 @@ impl Game<'_> {
|
||||
&world,
|
||||
quit,
|
||||
&self.friends_view(),
|
||||
self.external_server(),
|
||||
)
|
||||
} else {
|
||||
pause_buttons(w, h)
|
||||
pause_buttons(w, h, self.external_server())
|
||||
.into_iter()
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
.map(|b| b.4)
|
||||
@@ -295,3 +320,19 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::pause_buttons;
|
||||
|
||||
#[test]
|
||||
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
|
||||
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
|
||||
let buttons = pause_buttons(427.0, 240.0, remote);
|
||||
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
|
||||
assert_eq!(quit.3, label);
|
||||
assert!(quit.5);
|
||||
assert!(buttons.iter().any(|b| b.4 == 9 && b.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,15 @@ impl GameRules {
|
||||
pub fn bool(&self, name: &str) -> bool {
|
||||
matches!(self.get(name), Some(Value::Bool(true)))
|
||||
}
|
||||
/// Parse a network rule value, retaining type and range validation.
|
||||
pub fn set_string(&mut self, name: &str, text: &str) -> bool {
|
||||
let value = match self.get(name) {
|
||||
Some(Value::Bool(_)) => text.parse::<bool>().ok().map(Value::Bool),
|
||||
Some(Value::Int(_)) => text.parse::<i32>().ok().map(Value::Int),
|
||||
None => None,
|
||||
};
|
||||
value.is_some_and(|v| self.set(name, v))
|
||||
}
|
||||
pub fn int(&self, name: &str) -> i32 {
|
||||
match self.get(name) {
|
||||
Some(Value::Int(v)) => v,
|
||||
@@ -250,6 +259,22 @@ impl GameRules {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn network_values_validate_rule_names_types_and_bounds() {
|
||||
let mut rules = GameRules::default();
|
||||
assert!(rules.set_string("minecraft:keep_inventory", "true"));
|
||||
assert!(rules.bool("keep_inventory"));
|
||||
assert!(!rules.set_string("minecraft:keep_inventory", "1"));
|
||||
assert!(rules.bool("keep_inventory"));
|
||||
assert!(rules.set_string("minecraft:random_tick_speed", "7"));
|
||||
assert_eq!(rules.int("random_tick_speed"), 7);
|
||||
assert!(!rules.set_string("minecraft:random_tick_speed", "-1"));
|
||||
assert!(!rules.set_string("minecraft:random_tick_speed", "999999999999"));
|
||||
assert!(!rules.set_string("minecraft:unknown", "true"));
|
||||
assert!(!rules.set_string("other:keep_inventory", "false"));
|
||||
assert_eq!(rules.int("random_tick_speed"), 7);
|
||||
}
|
||||
#[test]
|
||||
fn defaults_bounds_and_round_trip() {
|
||||
let mut rules = GameRules::default();
|
||||
|
||||
@@ -75,6 +75,9 @@ packet!(BlockDestruction { id: i32 => VarInt, pos: [i32; 3] => Pos, progress: u8
|
||||
packet!(BlockEntityData { pos: [i32; 3] => Pos, kind: i32 => VarInt, tag: Tag => Nbt });
|
||||
packet!(BlockUpdate { pos: [i32; 3] => Pos, state: i32 => VarInt });
|
||||
packet!(ChangeDifficulty { difficulty: i32 => VarInt, locked: bool => Bool });
|
||||
// ResourceKey<GameRule<?>> is an identifier, followed by its string value.
|
||||
packet!(GameRuleEntry { name: String => Ident, value: String => Str });
|
||||
packet!(GameRuleValues { entries: Vec<GameRuleEntry> => List<GameRuleEntry> });
|
||||
packet!(ChunkBatchFinished { size: i32 => VarInt });
|
||||
packet!(ContainerClose { container: i32 => VarInt });
|
||||
packet!(ContainerSetContent {
|
||||
@@ -999,6 +1002,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
|
||||
EntityPositionSync(EntityPositionSync) = ENTITY_POSITION_SYNC,
|
||||
ForgetLevelChunk(ForgetLevelChunk) = FORGET_LEVEL_CHUNK,
|
||||
GameEvent(GameEvent) = GAME_EVENT,
|
||||
GameRuleValues(GameRuleValues) = GAME_RULE_VALUES,
|
||||
HurtAnimation(HurtAnimation) = HURT_ANIMATION,
|
||||
KeepAlive(KeepAlive) = KEEP_ALIVE,
|
||||
InitializeBorder(InitializeBorder) = INITIALIZE_BORDER,
|
||||
@@ -1097,6 +1101,9 @@ packet!(Chat {
|
||||
last_seen: LastSeenUpdate => LastSeenUpdate,
|
||||
});
|
||||
packet!(ChatCommand { command: String => Str });
|
||||
packet!(ChangeDifficultyRequest { difficulty: i32 => VarInt });
|
||||
packet!(LockDifficulty { locked: bool => Bool });
|
||||
packet!(SetGameRule { entries: Vec<GameRuleEntry> => List<GameRuleEntry> });
|
||||
packet!(ChunkBatchReceived { chunks_per_tick: f32 => Float });
|
||||
packet!(
|
||||
/// ServerboundClientCommandPacket.Action: 0 respawn, 1 request stats, 2 request game rules.
|
||||
@@ -1180,6 +1187,9 @@ impl Codec<[f32; 3]> for Cursor {
|
||||
}
|
||||
|
||||
packet_enum!(Serverbound, crate::ids::play::serverbound {
|
||||
ChangeDifficulty(ChangeDifficultyRequest) = CHANGE_DIFFICULTY,
|
||||
LockDifficulty(LockDifficulty) = LOCK_DIFFICULTY,
|
||||
SetGameRule(SetGameRule) = SET_GAME_RULE,
|
||||
AcceptTeleportation(AcceptTeleportation) = ACCEPT_TELEPORTATION,
|
||||
Attack(Attack) = ATTACK,
|
||||
ChangeGameMode(ChangeGameMode) = CHANGE_GAME_MODE,
|
||||
@@ -1220,6 +1230,46 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{chunk::BlockEntityInfo, packets::round_trip, text::literal};
|
||||
|
||||
#[test]
|
||||
fn world_options_packets_match_vanilla_stream_codecs() {
|
||||
// mcsrc.dev 26.3: difficulty is a VarInt, locking is a Boolean,
|
||||
// rule entries are ResourceKey identifiers followed by UTF-8 values.
|
||||
let difficulty = Serverbound::ChangeDifficulty(ChangeDifficultyRequest { difficulty: 3 });
|
||||
assert_eq!(&difficulty.encode()[1..], &[3]);
|
||||
round_trip(difficulty, Serverbound::encode, Serverbound::decode);
|
||||
let lock = Serverbound::LockDifficulty(LockDifficulty { locked: true });
|
||||
assert_eq!(&lock.encode()[1..], &[1]);
|
||||
round_trip(lock, Serverbound::encode, Serverbound::decode);
|
||||
let payload = b"\x01\x18minecraft:keep_inventory\x04true";
|
||||
let entry = GameRuleEntry {
|
||||
name: "minecraft:keep_inventory".into(),
|
||||
value: "true".into(),
|
||||
};
|
||||
let set = Serverbound::SetGameRule(SetGameRule {
|
||||
entries: vec![entry.clone()],
|
||||
});
|
||||
assert_eq!(&set.encode()[1..], payload);
|
||||
assert_eq!(
|
||||
Serverbound::decode(crate::ids::play::serverbound::SET_GAME_RULE, payload).unwrap(),
|
||||
set
|
||||
);
|
||||
let values = Clientbound::GameRuleValues(GameRuleValues {
|
||||
entries: vec![entry],
|
||||
});
|
||||
assert_eq!(&values.encode()[1..], payload);
|
||||
assert_eq!(
|
||||
Clientbound::decode(crate::ids::play::clientbound::GAME_RULE_VALUES, payload).unwrap(),
|
||||
values
|
||||
);
|
||||
assert!(
|
||||
Serverbound::decode(
|
||||
crate::ids::play::serverbound::SET_GAME_RULE,
|
||||
&payload[..payload.len() - 1]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn spawn() -> SpawnInfo {
|
||||
SpawnInfo {
|
||||
dimension_type: 0,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
@@ -0,0 +1,55 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import io.netty.buffer.Unpooled;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.protocol.game.ClientboundGameRuleValuesPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundChangeDifficultyPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundLockDifficultyPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundSetGameRulePacket;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.Difficulty;
|
||||
|
||||
/** Encode the same settings through the pinned game's own StreamCodecs. */
|
||||
public final class VanillaWorldOptionsOracle {
|
||||
public static void main(String[] args) throws Exception {
|
||||
var output = new ArrayList<String>();
|
||||
for (String line : Files.readAllLines(Path.of(args[0]))) {
|
||||
String[] fields = line.split("\t");
|
||||
var buffer = new FriendlyByteBuf(Unpooled.buffer());
|
||||
switch (fields[0]) {
|
||||
case "difficulty" -> ServerboundChangeDifficultyPacket.STREAM_CODEC.encode(buffer,
|
||||
new ServerboundChangeDifficultyPacket(Difficulty.byId(Integer.parseInt(fields[1]))));
|
||||
case "lock" -> ServerboundLockDifficultyPacket.STREAM_CODEC.encode(buffer,
|
||||
new ServerboundLockDifficultyPacket(Boolean.parseBoolean(fields[1])));
|
||||
case "set", "values" -> {
|
||||
var entries = fields.length == 1 ? List.<ServerboundSetGameRulePacket.Entry>of()
|
||||
: List.of(new ServerboundSetGameRulePacket.Entry(ResourceKey.create(
|
||||
Registries.GAME_RULE, Identifier.withDefaultNamespace(fields[1])), fields[2]));
|
||||
if (fields[0].equals("set")) {
|
||||
ServerboundSetGameRulePacket.STREAM_CODEC.encode(buffer,
|
||||
new ServerboundSetGameRulePacket(entries));
|
||||
} else {
|
||||
var values = entries.isEmpty() ? Map.<ResourceKey<net.minecraft.world.level.gamerules.GameRule<?>>, String>of()
|
||||
: Map.of(entries.getFirst().gameRuleKey(), entries.getFirst().value());
|
||||
ClientboundGameRuleValuesPacket.STREAM_CODEC.encode(buffer,
|
||||
new ClientboundGameRuleValuesPacket(values));
|
||||
}
|
||||
}
|
||||
default -> throw new IllegalArgumentException(line);
|
||||
}
|
||||
byte[] bytes = new byte[buffer.readableBytes()];
|
||||
buffer.readBytes(bytes);
|
||||
output.add(HexFormat.of().formatHex(bytes));
|
||||
buffer.release();
|
||||
}
|
||||
Files.write(Path.of(args[1]), output);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare World Options packet payloads with the pinned vanilla codecs."""
|
||||
try:
|
||||
from . import lab, oracle_harness as harness
|
||||
except ImportError:
|
||||
import lab, oracle_harness as harness
|
||||
|
||||
|
||||
def cases(build):
|
||||
lines = [f'difficulty\t{i}' for i in range(4)] + ['lock\tfalse', 'lock\ttrue']
|
||||
for kind in ('set', 'values'):
|
||||
lines.append(kind)
|
||||
for name, values in [('keep_inventory', ['false', 'true']),
|
||||
('random_tick_speed', ['0', '3', '2147483647'])]:
|
||||
lines.extend(f'{kind}\t{name}\t{value}' for value in values)
|
||||
path = build / 'cases.txt'
|
||||
path.write_text('\n'.join(lines) + '\n')
|
||||
return path, len(lines)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
harness.differential(
|
||||
'world-options-parity', 'VanillaWorldOptionsOracle', 'world_options_fixture', cases,
|
||||
'ChangeDifficulty, LockDifficulty, SetGameRule and GameRuleValues payloads',
|
||||
sources=[lab.ROOT / 'crates/horizon-protocol/src/packets/play.rs'], expected_lines=18)
|
||||
Reference in new issue
Block a user