mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2818c89f24 | ||
|
|
9821bb7ef5 | ||
|
|
b9f3821f24 | ||
|
|
4ff23b686d | ||
|
|
30f0c715b2 | ||
|
|
a4feb65161 | ||
|
|
5f12705a29 | ||
|
|
e4741b7793 | ||
|
|
805d5532e5 | ||
|
|
b2784363a1 | ||
|
|
a67a323959 | ||
|
|
45cbb4f7d6 |
No files matched your search
@@ -8,6 +8,7 @@ pub mod block;
|
||||
pub mod codec;
|
||||
pub mod component;
|
||||
pub mod item;
|
||||
pub mod network;
|
||||
pub mod packrat;
|
||||
pub mod selector;
|
||||
pub mod snbt;
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! The command tree a server sends (ClientboundCommandsPacket) as a
|
||||
//! dispatcher the chat parses, colours and completes with
|
||||
//! (ClientPacketListener.handleCommands).
|
||||
//!
|
||||
//! The packet holds each argument's parser and its network properties. A
|
||||
//! node the client's own vanilla tree has at the same path takes that node's
|
||||
//! argument type and suggestion provider (the parsers' suggestion lists and
|
||||
//! the custom providers are the same code on both sides); any other node
|
||||
//! (another server's or a mod's) gets the type its parser and properties
|
||||
//! describe.
|
||||
use super::{ArgType, Dispatcher, Node, NodeKind, Suggests};
|
||||
use horizon_protocol::packets::play::{ARGUMENT_TYPES, ArgumentProperties, Commands};
|
||||
use std::{
|
||||
collections::{HashSet, VecDeque},
|
||||
sync::{Mutex, OnceLock},
|
||||
};
|
||||
|
||||
/// The command id of every executable node of a server's tree: the chat
|
||||
/// sends the command, the server runs it.
|
||||
pub const SERVER_COMMAND: &str = "server";
|
||||
|
||||
/// The dispatcher for a server's tree. `local` is the client's own tree
|
||||
/// (vanilla's), whose matching nodes lend their types and providers.
|
||||
pub fn from_packet(packet: &Commands, local: &Dispatcher) -> Dispatcher {
|
||||
let count = packet.nodes.len();
|
||||
let root = usize::try_from(packet.root)
|
||||
.ok()
|
||||
.filter(|&r| r < count)
|
||||
.unwrap_or(0);
|
||||
// The packet's root becomes node 0, the others follow in packet order.
|
||||
let index = |i: usize| -> usize {
|
||||
if i == root {
|
||||
0
|
||||
} else if i < root {
|
||||
i + 1
|
||||
} else {
|
||||
i
|
||||
}
|
||||
};
|
||||
let valid = |c: &i32| usize::try_from(*c).ok().filter(|&c| c < count);
|
||||
// The local node at the same path, found breadth first from the roots
|
||||
// along children (redirects are not paths of their own).
|
||||
let mut matched: Vec<Option<usize>> = vec![None; count];
|
||||
matched[root] = Some(0);
|
||||
let mut queue = VecDeque::from([root]);
|
||||
let mut seen = HashSet::from([root]);
|
||||
while let Some(n) = queue.pop_front() {
|
||||
for c in packet.nodes[n].children.iter().filter_map(valid) {
|
||||
if !seen.insert(c) {
|
||||
continue;
|
||||
}
|
||||
let node = &packet.nodes[c];
|
||||
matched[c] = matched[n].and_then(|l| {
|
||||
local.nodes[l].children.iter().copied().find(|&lc| {
|
||||
let ln = &local.nodes[lc];
|
||||
ln.name() == node.name
|
||||
&& matches!(
|
||||
(&ln.kind, node.kind),
|
||||
(NodeKind::Literal(_), 1) | (NodeKind::Argument { .. }, 2)
|
||||
)
|
||||
})
|
||||
});
|
||||
queue.push_back(c);
|
||||
}
|
||||
}
|
||||
let mut nodes = vec![None; count];
|
||||
for (i, node) in packet.nodes.iter().enumerate() {
|
||||
let local_node = matched[i].map(|l| &local.nodes[l]);
|
||||
let kind = match node.kind {
|
||||
1 => NodeKind::Literal(node.name.clone()),
|
||||
2 => {
|
||||
let ty = match local_node.map(|l| &l.kind) {
|
||||
Some(NodeKind::Argument { ty, .. }) => ty.clone(),
|
||||
_ => node
|
||||
.parser
|
||||
.as_ref()
|
||||
.map_or(ArgType::Word, |(id, props)| argument_type(*id, props)),
|
||||
};
|
||||
NodeKind::Argument {
|
||||
name: node.name.clone(),
|
||||
ty,
|
||||
}
|
||||
}
|
||||
_ => NodeKind::Root,
|
||||
};
|
||||
let suggests = match local_node {
|
||||
Some(l) => l.suggests.clone(),
|
||||
None => node.suggestions.as_deref().and_then(provider),
|
||||
};
|
||||
nodes[index(i)] = Some(Node {
|
||||
kind: if i == root { NodeKind::Root } else { kind },
|
||||
children: node.children.iter().filter_map(valid).map(index).collect(),
|
||||
command: node.executable.then_some(SERVER_COMMAND),
|
||||
redirect: node.redirect.as_ref().and_then(valid).map(index),
|
||||
// The server sent only what the player may use.
|
||||
permission: 0,
|
||||
suggests,
|
||||
});
|
||||
}
|
||||
let mut d = Dispatcher::new();
|
||||
if count > 0 {
|
||||
d.nodes = nodes.into_iter().flatten().collect();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// SuggestionProviders by id (`ask_server` asks the server; the client's
|
||||
/// own argument suggestions stand in for it).
|
||||
fn provider(id: &str) -> Option<Suggests> {
|
||||
match id {
|
||||
"minecraft:summonable_entities" => Some(Suggests::SummonableEntities),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A registry name kept for the life of the game (argument types hold
|
||||
/// `&'static str`): each distinct name is stored once.
|
||||
fn intern(name: &str) -> &'static str {
|
||||
static NAMES: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
|
||||
let mut names = NAMES
|
||||
.get_or_init(Default::default)
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(&n) = names.get(name) {
|
||||
return n;
|
||||
}
|
||||
let n: &'static str = Box::leak(name.to_owned().into_boxed_str());
|
||||
names.insert(n);
|
||||
n
|
||||
}
|
||||
|
||||
/// The argument type a parser and its properties describe
|
||||
/// (ArgumentTypeInfo.Template.instantiate).
|
||||
pub fn argument_type(id: i32, props: &[u8]) -> ArgType {
|
||||
let Some(name) = usize::try_from(id).ok().and_then(|i| ARGUMENT_TYPES.get(i)) else {
|
||||
return ArgType::Word;
|
||||
};
|
||||
let p = ArgumentProperties::decode(id, props).unwrap_or(ArgumentProperties::None);
|
||||
let registry = || match &p {
|
||||
ArgumentProperties::Registry(r) => intern(r),
|
||||
_ => "",
|
||||
};
|
||||
match *name {
|
||||
"brigadier:bool" => ArgType::Bool,
|
||||
"brigadier:integer" => match p {
|
||||
ArgumentProperties::Integer { min, max } => ArgType::Integer {
|
||||
min: min.unwrap_or(i32::MIN),
|
||||
max: max.unwrap_or(i32::MAX),
|
||||
},
|
||||
_ => ArgType::Integer {
|
||||
min: i32::MIN,
|
||||
max: i32::MAX,
|
||||
},
|
||||
},
|
||||
// No long type of its own: a double over the same bounds reads
|
||||
// the same digits.
|
||||
"brigadier:long" => match p {
|
||||
ArgumentProperties::Long { min, max } => ArgType::Double {
|
||||
min: min.map_or(f64::MIN, |v| v as f64),
|
||||
max: max.map_or(f64::MAX, |v| v as f64),
|
||||
},
|
||||
_ => ArgType::Double {
|
||||
min: f64::MIN,
|
||||
max: f64::MAX,
|
||||
},
|
||||
},
|
||||
"brigadier:float" => match p {
|
||||
ArgumentProperties::Float { min, max } => ArgType::Float {
|
||||
min: min.unwrap_or(f32::MIN),
|
||||
max: max.unwrap_or(f32::MAX),
|
||||
},
|
||||
_ => ArgType::Float {
|
||||
min: f32::MIN,
|
||||
max: f32::MAX,
|
||||
},
|
||||
},
|
||||
"brigadier:double" => match p {
|
||||
ArgumentProperties::Double { min, max } => ArgType::Double {
|
||||
min: min.unwrap_or(f64::MIN),
|
||||
max: max.unwrap_or(f64::MAX),
|
||||
},
|
||||
_ => ArgType::Double {
|
||||
min: f64::MIN,
|
||||
max: f64::MAX,
|
||||
},
|
||||
},
|
||||
"brigadier:string" => match p {
|
||||
ArgumentProperties::String(1) => ArgType::Str,
|
||||
ArgumentProperties::String(2) => ArgType::Greedy,
|
||||
_ => ArgType::Word,
|
||||
},
|
||||
"minecraft:entity" => match p {
|
||||
ArgumentProperties::Entity {
|
||||
single,
|
||||
players_only,
|
||||
} => ArgType::Entity {
|
||||
single,
|
||||
players: players_only,
|
||||
},
|
||||
_ => ArgType::Entity {
|
||||
single: false,
|
||||
players: false,
|
||||
},
|
||||
},
|
||||
"minecraft:game_profile" => ArgType::GameProfile,
|
||||
"minecraft:block_pos" => ArgType::BlockPos,
|
||||
"minecraft:column_pos" => ArgType::ColumnPos,
|
||||
"minecraft:vec3" => ArgType::Vec3 { center: true },
|
||||
"minecraft:vec2" => ArgType::Vec2,
|
||||
"minecraft:block_state" => ArgType::BlockState,
|
||||
"minecraft:block_predicate" => ArgType::BlockPredicate,
|
||||
"minecraft:item_stack" => ArgType::Item,
|
||||
"minecraft:item_predicate" => ArgType::ItemPredicate,
|
||||
"minecraft:team_color" => ArgType::TeamColor,
|
||||
"minecraft:hex_color" => ArgType::HexColor,
|
||||
"minecraft:component" => ArgType::Component,
|
||||
"minecraft:style" => ArgType::Style,
|
||||
"minecraft:message" => ArgType::Message,
|
||||
"minecraft:nbt_compound_tag" => ArgType::Nbt,
|
||||
"minecraft:nbt_tag" => ArgType::NbtTag,
|
||||
"minecraft:nbt_path" => ArgType::NbtPath,
|
||||
"minecraft:objective" => ArgType::Objective,
|
||||
"minecraft:objective_criteria" => ArgType::Criteria,
|
||||
"minecraft:operation" => ArgType::Operation,
|
||||
"minecraft:particle" => ArgType::Particle,
|
||||
"minecraft:angle" => ArgType::Angle,
|
||||
"minecraft:rotation" => ArgType::Rotation,
|
||||
"minecraft:scoreboard_slot" => ArgType::ScoreboardSlot,
|
||||
"minecraft:score_holder" => ArgType::ScoreHolder {
|
||||
multiple: matches!(p, ArgumentProperties::ScoreHolder { multiple: true }),
|
||||
},
|
||||
"minecraft:swizzle" => ArgType::Swizzle,
|
||||
"minecraft:team" => ArgType::Team,
|
||||
"minecraft:item_slot" => ArgType::Slot,
|
||||
"minecraft:item_slots" => ArgType::Slots,
|
||||
"minecraft:resource_location" => ArgType::Id("none"),
|
||||
"minecraft:function" => ArgType::Function,
|
||||
"minecraft:entity_anchor" => ArgType::Anchor,
|
||||
"minecraft:int_range" => ArgType::IntRange,
|
||||
"minecraft:float_range" => ArgType::FloatRange,
|
||||
"minecraft:dimension" => ArgType::Dimension,
|
||||
"minecraft:gamemode" => ArgType::GameMode,
|
||||
"minecraft:time" => ArgType::Time {
|
||||
min: match p {
|
||||
ArgumentProperties::Time { min } => min,
|
||||
_ => 0,
|
||||
},
|
||||
},
|
||||
"minecraft:resource_or_tag" => ArgType::ResOrTag(registry()),
|
||||
"minecraft:resource_or_tag_key" => ArgType::ResOrTagKey(registry()),
|
||||
"minecraft:resource" => ArgType::Res(registry()),
|
||||
"minecraft:resource_key" => ArgType::ResKey(registry()),
|
||||
"minecraft:resource_selector" => ArgType::ResourceSelector(registry()),
|
||||
"minecraft:template_mirror" => ArgType::Enum(&["none", "front_back", "left_right"]),
|
||||
"minecraft:template_rotation" => {
|
||||
ArgType::Enum(&["none", "clockwise_90", "180", "counterclockwise_90"])
|
||||
}
|
||||
"minecraft:heightmap" => ArgType::Enum(&[
|
||||
"world_surface",
|
||||
"motion_blocking",
|
||||
"motion_blocking_no_leaves",
|
||||
"ocean_floor",
|
||||
]),
|
||||
"minecraft:swing_animation" => ArgType::Enum(&["none", "whack", "stab"]),
|
||||
"minecraft:loot_table" => ArgType::ResourceOrId("minecraft:loot_table"),
|
||||
"minecraft:loot_predicate" => ArgType::ResourceOrId("minecraft:predicate"),
|
||||
"minecraft:loot_modifier" => ArgType::ResourceOrId("minecraft:item_modifier"),
|
||||
"minecraft:context_float_provider" => {
|
||||
ArgType::ResourceOrId("minecraft:context_float_provider")
|
||||
}
|
||||
"minecraft:context_int_provider" => ArgType::ResourceOrId("minecraft:context_int_provider"),
|
||||
"minecraft:slot_source" => ArgType::SlotSource,
|
||||
"minecraft:dialog" => ArgType::ResourceOrId("minecraft:dialog"),
|
||||
"minecraft:feature" => ArgType::ResourceOrId("minecraft:worldgen/feature"),
|
||||
"minecraft:uuid" => ArgType::Uuid,
|
||||
_ => ArgType::Word,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
packets::play::CommandNode,
|
||||
};
|
||||
|
||||
fn literal(name: &str, children: Vec<i32>, executable: bool) -> CommandNode {
|
||||
CommandNode {
|
||||
kind: 1,
|
||||
executable,
|
||||
restricted: false,
|
||||
children,
|
||||
redirect: None,
|
||||
name: name.into(),
|
||||
parser: None,
|
||||
suggestions: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn argument(name: &str, parser: &str, props: Vec<u8>, executable: bool) -> CommandNode {
|
||||
let id = ARGUMENT_TYPES.iter().position(|p| *p == parser).unwrap() as i32;
|
||||
CommandNode {
|
||||
kind: 2,
|
||||
executable,
|
||||
restricted: false,
|
||||
children: Vec::new(),
|
||||
redirect: None,
|
||||
name: name.into(),
|
||||
parser: Some((id, props)),
|
||||
suggestions: Some("minecraft:ask_server".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A tree with the root last (as vanilla's numbering need not put it
|
||||
/// first), a vanilla command and one the client does not know.
|
||||
fn packet() -> Commands {
|
||||
let mut range = Writer::new();
|
||||
range.u8(3);
|
||||
range.i32(1);
|
||||
range.i32(64);
|
||||
Commands {
|
||||
nodes: vec![
|
||||
literal("give", vec![2], false),
|
||||
literal("warp", vec![3], false),
|
||||
argument("targets", "minecraft:entity", vec![2], false),
|
||||
argument("count", "brigadier:integer", range.data, true),
|
||||
CommandNode {
|
||||
kind: 0,
|
||||
executable: false,
|
||||
restricted: false,
|
||||
children: vec![0, 1],
|
||||
redirect: None,
|
||||
name: String::new(),
|
||||
parser: None,
|
||||
suggestions: None,
|
||||
},
|
||||
],
|
||||
root: 4,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn argument_nodes_round_trip() {
|
||||
let p = packet();
|
||||
let mut w = Writer::new();
|
||||
Commands::write(&p, &mut w);
|
||||
let back = Commands::read(&mut Reader::new(&w.data)).unwrap();
|
||||
assert_eq!(back, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_tree_becomes_a_dispatcher() {
|
||||
let local = super::super::vanilla::dispatcher();
|
||||
let d = from_packet(&packet(), local);
|
||||
assert!(matches!(d.nodes[0].kind, NodeKind::Root));
|
||||
let warp = d.find("warp").expect("warp");
|
||||
let count = d.nodes[warp].children[0];
|
||||
match &d.nodes[count].kind {
|
||||
NodeKind::Argument {
|
||||
ty: ArgType::Integer { min, max },
|
||||
..
|
||||
} => assert_eq!((*min, *max), (1, 64)),
|
||||
other => panic!("count is {other:?}"),
|
||||
}
|
||||
assert_eq!(d.nodes[count].command, Some(SERVER_COMMAND));
|
||||
// `give <targets>` is vanilla's: it keeps the client's type.
|
||||
let give = d.find("give").expect("give");
|
||||
let targets = d.nodes[give].children[0];
|
||||
assert!(matches!(
|
||||
d.nodes[targets].kind,
|
||||
NodeKind::Argument {
|
||||
ty: ArgType::Entity {
|
||||
single: false,
|
||||
players: true
|
||||
},
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_argument_type_decodes() {
|
||||
for (id, name) in ARGUMENT_TYPES.iter().enumerate() {
|
||||
let mut w = Writer::new();
|
||||
match *name {
|
||||
"brigadier:integer" | "brigadier:long" | "brigadier:float" | "brigadier:double" => {
|
||||
w.u8(0)
|
||||
}
|
||||
"brigadier:string" => w.varint(2),
|
||||
"minecraft:entity" | "minecraft:score_holder" => w.u8(1),
|
||||
"minecraft:time" => w.i32(0),
|
||||
n if n.starts_with("minecraft:resource") && n != "minecraft:resource_location" => {
|
||||
w.string("minecraft:item")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut r = Reader::new(&w.data);
|
||||
ArgumentProperties::read(&mut r, id as i32).unwrap();
|
||||
assert_eq!(r.remaining(), 0, "{name}");
|
||||
let _ = argument_type(id as i32, &w.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ mod lan_trading;
|
||||
mod lan_waypoints;
|
||||
mod leads;
|
||||
mod level_particles;
|
||||
mod levels;
|
||||
mod lightning;
|
||||
mod llamas;
|
||||
mod loom;
|
||||
@@ -247,8 +248,9 @@ struct Options {
|
||||
benchmark: Option<String>,
|
||||
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
|
||||
dev_mods: Vec<PathBuf>,
|
||||
/// `--server-thread` or `HORIZON_SERVER_THREAD=1`: single player runs on
|
||||
/// an integrated server thread (game/integrated.rs).
|
||||
/// Single player runs on an integrated server thread (game/integrated.rs),
|
||||
/// as vanilla's does: the default. `--no-server-thread` or
|
||||
/// `HORIZON_SERVER_THREAD=0` runs the world in the frame instead.
|
||||
server_thread: bool,
|
||||
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
|
||||
integrated: bool,
|
||||
@@ -303,7 +305,10 @@ impl Options {
|
||||
dedicated: None,
|
||||
benchmark: None,
|
||||
dev_mods: Vec::new(),
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").is_ok_and(|v| v == "1"),
|
||||
// The command harness (HORIZON_COMMAND_SCRIPT) drives the world
|
||||
// itself, without the event loop: it stays in the frame.
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
|
||||
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
|
||||
integrated: false,
|
||||
};
|
||||
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
|
||||
@@ -312,7 +317,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition 26.3 in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause."
|
||||
"Horizon — Minecraft Java Edition 26.3 in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --no-server-thread Run single player's world in the frame, not on its server thread\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause."
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -326,6 +331,7 @@ impl Options {
|
||||
"--offscreen" => o.offscreen = true,
|
||||
"--control" => o.control = Some(horizon_client::control::default_path()),
|
||||
"--server-thread" => o.server_thread = true,
|
||||
"--no-server-thread" => o.server_thread = false,
|
||||
_ => {
|
||||
let value = args
|
||||
.next()
|
||||
@@ -975,8 +981,14 @@ struct Game<'a> {
|
||||
world_root: PathBuf,
|
||||
/// A player carried through a portal, re-placed once the destination loads.
|
||||
travelling: Option<(PlayerMotion, Vec3)>,
|
||||
/// A server's lone player on its way to another level.
|
||||
server_trip: Option<server_travel::Trip>,
|
||||
/// A server's players on their way to another level, each waiting for
|
||||
/// the destination to load around them.
|
||||
server_trips: Vec<server_travel::Trip>,
|
||||
/// A server's levels other than the one in place (game/levels.rs).
|
||||
levels: levels::Levels,
|
||||
/// Which pass of a server tick runs (`tick_server`): the first one reads
|
||||
/// the network, the last one ends the players' tick.
|
||||
level_pass: levels::Pass,
|
||||
/// Nether portals: the player's, items' and mobs' (game/nether_portal.rs).
|
||||
portals: nether_portal::Portals,
|
||||
/// The pending travel goes through an End portal (EndPortalBlock destinations).
|
||||
@@ -1166,7 +1178,7 @@ struct Game<'a> {
|
||||
/// The local player as a model (third person, the inventory).
|
||||
player_model: Option<Mob>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
enum Dim {
|
||||
Overworld,
|
||||
Nether,
|
||||
@@ -3677,6 +3689,11 @@ impl Game<'_> {
|
||||
) || self.book.is_some())
|
||||
}
|
||||
fn capture(&mut self, capture: bool) {
|
||||
// A guest's action (`as_guest`) opens and closes screens on the
|
||||
// guest's own client: the window's pointer stays the host's (#687).
|
||||
if self.lan_acting.is_some() {
|
||||
return;
|
||||
}
|
||||
// MouseHandler.grabMouse never captures an inactive window.
|
||||
if capture && !self.focused {
|
||||
return;
|
||||
@@ -3702,10 +3719,7 @@ impl Game<'_> {
|
||||
/// LevelLoadingScreen.onClose -> Gui.setScreen(null) -> grabMouse.
|
||||
fn capture_loaded_world(&mut self) {
|
||||
// Loading may finish after focus was lost or another screen opened.
|
||||
if self.title.is_none()
|
||||
&& self.screen == Screen::Playing
|
||||
&& !self.remote.as_ref().is_some_and(|r| r.chat_input.is_some())
|
||||
{
|
||||
if self.title.is_none() && self.screen == Screen::Playing {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
@@ -8447,18 +8461,10 @@ impl Game<'_> {
|
||||
if self.remote.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
self.streaming.save(&mut self.world, wait);
|
||||
let keys: Vec<_> = self.world.modified.drain().collect();
|
||||
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
|
||||
self.save
|
||||
.save_later(&chunks, self.pack.assets.registry, &self.res.biomes)?;
|
||||
if wait {
|
||||
self.save.flush();
|
||||
}
|
||||
self.save_entities(wait)?;
|
||||
self.save_level(wait)?;
|
||||
self.save_parked_levels(wait)?;
|
||||
self.save_maps();
|
||||
self.save_random_sequences();
|
||||
self.save_raids();
|
||||
self.save_fight();
|
||||
self.save_wandering_trader();
|
||||
self.save_portal_arrivals();
|
||||
@@ -8475,6 +8481,20 @@ impl Game<'_> {
|
||||
self.message = Some(("Saved the game".into(), Instant::now()));
|
||||
Ok(())
|
||||
}
|
||||
/// Save the level in place: its changed chunks, its entities and its raids.
|
||||
fn save_level(&mut self, wait: bool) -> Result<()> {
|
||||
self.streaming.save(&mut self.world, wait);
|
||||
let keys: Vec<_> = self.world.modified.drain().collect();
|
||||
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
|
||||
self.save
|
||||
.save_later(&chunks, self.pack.assets.registry, &self.res.biomes)?;
|
||||
if wait {
|
||||
self.save.flush();
|
||||
}
|
||||
self.save_entities(wait)?;
|
||||
self.save_raids();
|
||||
Ok(())
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
self.poll_shader_pack();
|
||||
let Some(size) = self
|
||||
@@ -9645,9 +9665,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.station_text(event.text.as_deref(), backspace);
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.remote_chat_open() => {
|
||||
self.remote_chat_key(&event)
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. }
|
||||
if self.screen == Screen::WorldOptions
|
||||
&& !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Escape)) =>
|
||||
@@ -9754,7 +9771,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
&& self.title.is_none()
|
||||
&& self.sign_edit.is_none()
|
||||
&& self.book.is_none()
|
||||
&& !self.remote_chat_open()
|
||||
{
|
||||
let turn = |k| f32::from(u8::from(self.keys.contains(&k)));
|
||||
let secs = elapsed.as_secs_f32().min(0.1);
|
||||
@@ -10167,7 +10183,9 @@ impl<'a> Game<'a> {
|
||||
dim: Dim::Overworld,
|
||||
world_root: PathBuf::new(),
|
||||
travelling: None,
|
||||
server_trip: None,
|
||||
server_trips: Vec::new(),
|
||||
levels: Default::default(),
|
||||
level_pass: Default::default(),
|
||||
portals: Default::default(),
|
||||
end_travel: false,
|
||||
respawn_config: None,
|
||||
|
||||
@@ -469,6 +469,9 @@ impl Game<'_> {
|
||||
"time": { "day_time": self.world.day_time, "day": self.world.day_time.div_euclid(24000), "time_of_day": self.world.day_time.rem_euclid(24000) },
|
||||
"weather": { "raining": self.world.weather.raining, "thundering": self.world.weather.thundering },
|
||||
"paused": self.pause,
|
||||
// Mouse movement turns the camera (`apply_mouse_look`): the real
|
||||
// pointer is grabbed, or an agent's `mouse-move` stands in for it.
|
||||
"pointer_grabbed": self.captured || self.agent_pointer,
|
||||
"player": player,
|
||||
"selected": self.selected + 1,
|
||||
"held": self.held().map(|s| self.stack_value(s)),
|
||||
@@ -613,6 +616,9 @@ impl Game<'_> {
|
||||
"screen": self.screen_label(),
|
||||
"buttons": buttons,
|
||||
});
|
||||
if let Some(chat) = &self.ui.chat_screen {
|
||||
v["chat_input"] = json!(chat.input.value);
|
||||
}
|
||||
if self.title.is_some() {
|
||||
v["menu"] = self.title_value();
|
||||
}
|
||||
@@ -631,6 +637,15 @@ impl Game<'_> {
|
||||
v["craft_size"] = json!(self.craft_size);
|
||||
v["station"] = json!(self.station.as_ref().map(|s| format!("{:?}", s.kind)));
|
||||
v["container"] = json!(self.container);
|
||||
// The recipe book's buttons (GUI pixels): the book button, then
|
||||
// the recipe buttons of the page, page arrows, filter and tabs.
|
||||
let book = self.recipe_book_targets();
|
||||
if !book.is_empty() {
|
||||
v["recipe_book"] = json!({
|
||||
"targets": book.iter().map(|t| t.rect).collect::<Vec<_>>(),
|
||||
"known": self.recipe_book_known(),
|
||||
});
|
||||
}
|
||||
v["slot_ids"] = json!(
|
||||
"0-8 hotbar, 9-35 inventory, 100+ crafting grid (row-major), 200 crafting result, 300+ container, 500-503 armor, 504 offhand, 600+ station"
|
||||
);
|
||||
@@ -1479,6 +1494,13 @@ impl Game<'_> {
|
||||
return Err("command TEXT".into());
|
||||
}
|
||||
let command = format!("/{text}");
|
||||
// Single player's console is its server thread's.
|
||||
if let Some(server) = &self.integrated {
|
||||
let chat = server
|
||||
.call(move |g| g.owner_console_command(&command))
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return ok(json!({ "ok": true, "chat": chat }));
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.send_chat(&command);
|
||||
return ok(json!({ "ok": true }));
|
||||
@@ -1764,11 +1786,21 @@ impl Game<'_> {
|
||||
if self.ticks < *report_tick && !timed_out {
|
||||
return None;
|
||||
}
|
||||
let after = pos.map(|p| self.block_json(p));
|
||||
let changed = match (*pos, *before) {
|
||||
(Some(p), Some(b)) => self.world.block(p[0], p[1], p[2]) != b,
|
||||
_ => false,
|
||||
};
|
||||
// A client of a server (single player's server thread, a LAN
|
||||
// guest) sees the block once the server's answer comes.
|
||||
if *place
|
||||
&& !changed
|
||||
&& self.remote.is_some()
|
||||
&& self.ticks < *report_tick + 20
|
||||
&& !timed_out
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let after = pos.map(|p| self.block_json(p));
|
||||
let used = delta(inventory, &self.inventory_counts());
|
||||
let mut v = json!({
|
||||
"ok": !*place || changed,
|
||||
|
||||
@@ -220,7 +220,11 @@ impl Game<'_> {
|
||||
levels
|
||||
}
|
||||
/// `applyEffects`: the powers for players within `levels * 10 + 10` (any height above).
|
||||
/// `BeaconBlockEntity.applyEffects`: every player in range.
|
||||
fn beacon_effects(&mut self, p: [i32; 3], b: &Beacon) {
|
||||
self.for_each_player(|g| g.beacon_effects_on_player(p, b));
|
||||
}
|
||||
fn beacon_effects_on_player(&mut self, p: [i32; 3], b: &Beacon) {
|
||||
let Some(primary) = b.primary.as_deref().and_then(effect_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,18 @@ use horizon_client::{
|
||||
text::{ClickEvent, Style, Text},
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The tree the chat parses and completes with: a server's, once it has
|
||||
/// sent one (ClientPacketListener.commands), else the game's own.
|
||||
fn chat_tree<'a>(
|
||||
remote: &'a Option<super::remote::Remote>,
|
||||
mods: &'a super::mods::ModsState,
|
||||
) -> &'a horizon_client::command::Dispatcher {
|
||||
match remote.as_ref().and_then(|r| r.commands.as_ref()) {
|
||||
Some(tree) => tree,
|
||||
None => mods.dispatcher(),
|
||||
}
|
||||
}
|
||||
use winit::{
|
||||
event::KeyEvent,
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
@@ -378,6 +390,10 @@ impl Game<'_> {
|
||||
/// The block the player is looking at, for coordinate suggestions.
|
||||
fn refresh_dynamic_registries(&mut self) {
|
||||
self.ui.registries.target_block = self.pick().map(|h| h.pos);
|
||||
// ClientPacketListener.getOnlinePlayerNames on a server.
|
||||
if let Some(names) = self.remote_online_names() {
|
||||
self.ui.registries.players = names;
|
||||
}
|
||||
}
|
||||
pub(super) fn open_chat(&mut self, initial: &str) {
|
||||
if self.player.is_none() {
|
||||
@@ -391,7 +407,7 @@ impl Game<'_> {
|
||||
let b = HudBatch::new(size);
|
||||
let m = Measure(&self.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: self.mods.dispatcher(),
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(
|
||||
@@ -449,7 +465,7 @@ impl Game<'_> {
|
||||
if let Some(shortcut) = self.edit_shortcut(event) {
|
||||
let m = Measure(&self.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: self.mods.dispatcher(),
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
@@ -495,7 +511,7 @@ impl Game<'_> {
|
||||
};
|
||||
let m = Measure(&self.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: self.mods.dispatcher(),
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
|
||||
@@ -521,7 +537,7 @@ impl Game<'_> {
|
||||
pub(super) fn chat_type(&mut self, text: &str) {
|
||||
let m = Measure(&self.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: self.mods.dispatcher(),
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
@@ -531,6 +547,11 @@ impl Game<'_> {
|
||||
/// ChatScreen.handleChatInput: history, then a command or a chat line.
|
||||
pub(super) fn handle_chat_input(&mut self, message: &str) {
|
||||
self.ui.chat.add_recent(message);
|
||||
// On a server (single player's own included) the server runs it.
|
||||
if self.remote.is_some() {
|
||||
self.send_chat(message);
|
||||
return;
|
||||
}
|
||||
if let Some(command) = message.strip_prefix('/') {
|
||||
self.run_command(command);
|
||||
} else {
|
||||
@@ -572,7 +593,7 @@ impl Game<'_> {
|
||||
});
|
||||
let m = Measure(&self.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: self.mods.dispatcher(),
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
@@ -617,7 +638,7 @@ impl Game<'_> {
|
||||
};
|
||||
let m = Measure(&self.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: self.mods.dispatcher(),
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
screen.resize(&m, &c, batch.width as i32, batch.height as i32);
|
||||
|
||||
@@ -54,12 +54,23 @@ pub struct Flight {
|
||||
/// Reply with every frame time too (`fly ... PITCH raw`).
|
||||
raw: bool,
|
||||
reply: Sender<String>,
|
||||
/// The world's smoothed tick time each frame (the F3 server line):
|
||||
/// the server thread's when single player runs on one.
|
||||
tick_ms: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Move the camera along the running flight; reply when it ends.
|
||||
pub(super) fn advance_flight(&mut self) {
|
||||
let Some(flight) = &self.flight else { return };
|
||||
let tick_ms = self
|
||||
.integrated
|
||||
.as_ref()
|
||||
.map_or(self.debug_charts.smoothed_tick_ms, |s| s.smoothed_tick_ms());
|
||||
let Some(flight) = self.flight.as_mut() else {
|
||||
return;
|
||||
};
|
||||
flight.tick_ms.push(tick_ms);
|
||||
let flight = &*flight;
|
||||
let t = (flight.started.elapsed().as_secs_f64() / flight.seconds).min(1.0);
|
||||
let (a, b) = (flight.from, flight.to);
|
||||
let d = [b.x - a.x, b.y - a.y, b.z - a.z];
|
||||
@@ -79,6 +90,9 @@ impl Game<'_> {
|
||||
&& let Some(flight) = self.flight.take()
|
||||
{
|
||||
let mut summary = self.frame_stats.summary();
|
||||
let n = flight.tick_ms.len().max(1) as f32;
|
||||
summary["tick_ms"] = (flight.tick_ms.iter().sum::<f32>() / n).into();
|
||||
summary["tick_ms_max"] = flight.tick_ms.iter().copied().fold(0.0, f32::max).into();
|
||||
if flight.raw {
|
||||
summary["frame_ms"] = self.frame_stats.frame_millis().into();
|
||||
summary["cpu_ms"] = self.frame_stats.cpu_millis().into();
|
||||
@@ -339,6 +353,14 @@ impl Game<'_> {
|
||||
.get(&item)
|
||||
.ok_or_else(|| format!("unknown item {item}"))?;
|
||||
let count: u32 = words.get(2).map_or(Ok(1), |c| c.parse())?;
|
||||
// Single player's inventory is its server thread's player's.
|
||||
if let Some(server) = &self.integrated {
|
||||
let command = format!("give @s {item} {count}");
|
||||
server
|
||||
.call(move |g| g.owner_console_command(&command))
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return Ok(Some(json!({ "ok": true, "given": count })));
|
||||
}
|
||||
let max = info.max_stack;
|
||||
let mut given = 0;
|
||||
// Stop once the inventory is full.
|
||||
@@ -369,7 +391,14 @@ impl Game<'_> {
|
||||
ok()
|
||||
}
|
||||
"time" => {
|
||||
self.world.day_time = arg(1)?.parse()?;
|
||||
let time: i64 = arg(1)?.parse()?;
|
||||
self.world.day_time = time;
|
||||
// Single player's clock is its server thread's.
|
||||
if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(move |g| g.world.day_time = time)
|
||||
.ok_or("the integrated server stopped")?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"summon" => {
|
||||
@@ -517,6 +546,7 @@ impl Game<'_> {
|
||||
seconds: n(7)?.max(0.1),
|
||||
raw: words.get(9) == Some(&"raw"),
|
||||
reply: reply.clone(),
|
||||
tick_ms: Vec::new(),
|
||||
});
|
||||
Ok(None)
|
||||
}
|
||||
@@ -606,18 +636,6 @@ impl Game<'_> {
|
||||
|
||||
fn control_key_down(&mut self, key: KeyCode) -> Result<()> {
|
||||
self.physical_keys.insert(key);
|
||||
// A server's chat input takes the keys first, as remote_chat_key does.
|
||||
if self.remote_chat_open() {
|
||||
use winit::keyboard::NamedKey;
|
||||
let named = match key {
|
||||
KeyCode::Escape => Some(NamedKey::Escape),
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => Some(NamedKey::Enter),
|
||||
KeyCode::Backspace => Some(NamedKey::Backspace),
|
||||
_ => None,
|
||||
};
|
||||
self.remote_chat_input(named, None);
|
||||
return Ok(());
|
||||
}
|
||||
if self.screen == Screen::Chat {
|
||||
self.chat_key_code(key, None);
|
||||
return Ok(());
|
||||
@@ -731,7 +749,9 @@ impl Game<'_> {
|
||||
"tridents": self.tridents.len(),
|
||||
"rockets": self.rockets.len(),
|
||||
"fight": self.dragon_status(),
|
||||
"carts": self.carts.iter().map(|c| serde_json::json!({"kind": c.kind.id(), "position": [c.position.x, c.position.y, c.position.z], "velocity": [c.velocity.x, c.velocity.y, c.velocity.z]})).collect::<Vec<_>>(),
|
||||
// A client of a server (single player's server thread) knows the
|
||||
// carts it was sent.
|
||||
"carts": self.remote_carts().unwrap_or_else(|| self.carts.iter().map(|c| serde_json::json!({"kind": c.kind.id(), "position": [c.position.x, c.position.y, c.position.z], "velocity": [c.velocity.x, c.velocity.y, c.velocity.z]})).collect::<Vec<_>>()),
|
||||
"riding": self.riding,
|
||||
"using": self.using,
|
||||
"render_distance": self.streaming.radius,
|
||||
|
||||
@@ -362,3 +362,11 @@ pub(super) struct CreakingState {
|
||||
/// Which block states are creaking hearts, by state id.
|
||||
hearts: Vec<bool>,
|
||||
}
|
||||
|
||||
impl CreakingState {
|
||||
/// Exchange the chunks scanned for creaking hearts with another level's
|
||||
/// (game/levels.rs).
|
||||
pub(super) fn swap_level(&mut self, other: &mut Self) {
|
||||
std::mem::swap(&mut self.scanned, &mut other.scanned);
|
||||
}
|
||||
}
|
||||
@@ -1358,25 +1358,52 @@ impl Game<'_> {
|
||||
/// mining fatigue III for five minutes unless a stronger or longer one
|
||||
/// is on, with the curse sound and the elder's apparition.
|
||||
fn elder_curse(&mut self, at: [f64; 3]) {
|
||||
let mut cursed = Vec::new();
|
||||
self.for_each_player(|g| {
|
||||
if g.elder_curse_player(at) {
|
||||
cursed.push(g.lan_acting);
|
||||
}
|
||||
});
|
||||
// A guest's client shows the apparition and plays the curse itself
|
||||
// (ClientboundGameEventPacket.GUARDIAN_ELDER_EFFECT).
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
for conn in cursed.into_iter().flatten() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&horizon_protocol::packets::play::Clientbound::GameEvent(
|
||||
horizon_protocol::packets::play::GameEvent {
|
||||
event: horizon_client::lan::hub::game_event::GUARDIAN_ELDER_EFFECT,
|
||||
param: 1.0,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The curse on the player in place; whether it took.
|
||||
fn elder_curse_player(&mut self, at: [f64; 3]) -> bool {
|
||||
if self.creative || self.spectator() {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
let d = p.position.subtract(Vec3::new(at[0], at[1], at[2]));
|
||||
if d.x * d.x + d.y * d.y + d.z * d.z >= 2500.0 || p.health.dead_or_dying() {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let active = self.potions.effects.get("minecraft:mining_fatigue");
|
||||
if active.is_some_and(|e| e.amplifier >= 2 && e.duration >= 1200) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.give_player_effect("minecraft:mining_fatigue", 6000, 2);
|
||||
self.audio
|
||||
.play("minecraft:entity.elder_guardian.curse", Some(at), 1.0, 1.0);
|
||||
self.creatures.elder_apparition = super::mob_blocks::APPARITION_TICKS;
|
||||
if self.lan_acting.is_none() {
|
||||
self.audio
|
||||
.play("minecraft:entity.elder_guardian.curse", Some(at), 1.0, 1.0);
|
||||
self.creatures.elder_apparition = super::mob_blocks::APPARITION_TICKS;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Damage of a given type to the player from a point.
|
||||
pub(super) fn mob_hits_player_kind(&mut self, amount: f32, from: [f64; 3], kind: &str) {
|
||||
|
||||
@@ -88,8 +88,7 @@ impl Game<'_> {
|
||||
|| self.book.is_some()
|
||||
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|
||||
|| self.recipe_search_focused()
|
||||
|| self.station_typing()
|
||||
|| self.remote_chat_open();
|
||||
|| self.station_typing();
|
||||
// Container screens use Escape, the inventory key and the hotbar and
|
||||
// drop keys first; any other screen closes on Escape.
|
||||
let screen_key = match self.screen {
|
||||
|
||||
@@ -565,18 +565,12 @@ impl Game<'_> {
|
||||
);
|
||||
self.save_game()?;
|
||||
}
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
if paused {
|
||||
// MinecraftServer.tickServer: only the connections run.
|
||||
self.tick_lan();
|
||||
} else {
|
||||
if !state.autosave {
|
||||
// `save-off`: the periodic save inside the tick is skipped.
|
||||
self.ticks = self.ticks.max(1);
|
||||
}
|
||||
self.tick()?;
|
||||
if !paused && !state.autosave {
|
||||
// `save-off`: the periodic save inside the tick is skipped.
|
||||
self.ticks = self.ticks.max(1);
|
||||
}
|
||||
// MinecraftServer.tickServer: while paused only the connections run.
|
||||
self.tick_server(paused)?;
|
||||
if idle_timeout > 0 {
|
||||
self.kick_idle(idle_timeout);
|
||||
}
|
||||
@@ -617,7 +611,13 @@ impl Game<'_> {
|
||||
let center = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| h.hub.guests.iter().find(|g| g.placed).map(|g| g.chunk()))
|
||||
.and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| h.hub.here(g))
|
||||
.map(|g| g.chunk())
|
||||
})
|
||||
.or_else(|| {
|
||||
self.spawn
|
||||
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4))
|
||||
|
||||
@@ -167,7 +167,10 @@ impl Game<'_> {
|
||||
Snapshot {
|
||||
screen: self.screen_label(),
|
||||
dimension: self.dim.id().to_string(),
|
||||
chat: self.ui.chat.received,
|
||||
chat: self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map_or(self.ui.chat.received, |r| r.chat_received),
|
||||
counts: if world.is_some() {
|
||||
self.inventory_counts()
|
||||
} else {
|
||||
@@ -189,8 +192,18 @@ impl Game<'_> {
|
||||
if let Some(before) = &self.events.last
|
||||
&& *before != now
|
||||
{
|
||||
let new_chat = (now.chat - before.chat).min(100) as usize;
|
||||
let lines = self.ui.chat.recent_lines(new_chat);
|
||||
let new_chat = now.chat.saturating_sub(before.chat).min(100) as usize;
|
||||
// The remote client keeps its own lines, oldest first.
|
||||
let lines = match &self.remote {
|
||||
Some(r) => r
|
||||
.chat
|
||||
.iter()
|
||||
.rev()
|
||||
.take(new_chat)
|
||||
.map(|(t, _)| t.clone())
|
||||
.collect(),
|
||||
None => self.ui.chat.recent_lines(new_chat),
|
||||
};
|
||||
for event in diff(before, &now, &lines) {
|
||||
self.events.push(self.ticks, event);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ impl Game<'_> {
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| {
|
||||
g.placed
|
||||
host.hub.here(g)
|
||||
&& g.loaded
|
||||
&& !g.dead
|
||||
&& !g.creative()
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
//! the world's options) with [`Integrated::call`], as vanilla's client calls
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! Opt-in for now (`--server-thread` or `HORIZON_SERVER_THREAD=1`): the
|
||||
//! player joins as a network player, and the server side of a network player
|
||||
//! does not do everything single player does yet.
|
||||
//! On by default. `--no-server-thread` or `HORIZON_SERVER_THREAD=0` runs
|
||||
//! the world in the frame instead (the escape hatch while the network
|
||||
//! player's side catches up with the in-frame one).
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources,
|
||||
lan::{LanSettings, Owner},
|
||||
@@ -101,6 +101,65 @@ impl Drop for Integrated {
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// On the server thread, the window's agent runs a command as single
|
||||
/// player's console does (`as_console`): as the world's owner, at the
|
||||
/// owner permission level. The feedback goes to the owner's chat and is
|
||||
/// returned.
|
||||
pub(super) fn owner_console_command(&mut self, command: &str) -> Vec<String> {
|
||||
let Some(owner) = self.serving.as_ref().map(|s| s.owner) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
if let Some(conn) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.uuid == owner)
|
||||
.map(|g| g.conn)
|
||||
{
|
||||
// The owner's level in place: between two ticks it is the one
|
||||
// ticked last (Commands.performCommand runs in the player's level).
|
||||
let dim = host.hub.guest(conn).and_then(|g| {
|
||||
[super::Dim::Overworld, super::Dim::Nether, super::Dim::End]
|
||||
.into_iter()
|
||||
.find(|d| g.dimension == d.id())
|
||||
});
|
||||
if let Some(dim) = dim
|
||||
&& let Err(e) = self.enter_level(Some(&mut host), dim)
|
||||
{
|
||||
log::error!("could not enter {}: {e}", dim.id());
|
||||
}
|
||||
let command = command.trim_start_matches('/').to_owned();
|
||||
let lines = self.as_guest(&mut host, conn, |g| {
|
||||
g.as_console(|g| {
|
||||
g.chat_capture = Some(Vec::new());
|
||||
g.run_command(&command);
|
||||
g.chat_capture.take().unwrap_or_default()
|
||||
})
|
||||
});
|
||||
self.sync_hub_settings(&mut host);
|
||||
for line in lines.unwrap_or_default() {
|
||||
out.push(line.plain());
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&horizon_protocol::packets::play::Clientbound::SystemChat(
|
||||
horizon_protocol::packets::play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The server thread's side of the window's handle (`Game::serving`).
|
||||
pub(super) struct Serving {
|
||||
flags: Arc<Flags>,
|
||||
@@ -314,14 +373,12 @@ impl Game<'_> {
|
||||
let mut next = Instant::now();
|
||||
while !flags.stop.load(Ordering::Relaxed) {
|
||||
self.run_tasks();
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
if flags.paused.load(Ordering::Relaxed) {
|
||||
// Only the connections run.
|
||||
self.tick_lan();
|
||||
self.tick_server(true)?;
|
||||
} else {
|
||||
let started = Instant::now();
|
||||
self.tick()?;
|
||||
self.tick_server(false)?;
|
||||
self.debug_charts.log_tick(started, started.elapsed());
|
||||
flags.smoothed_tick_ms.store(
|
||||
self.debug_charts.smoothed_tick_ms.to_bits(),
|
||||
|
||||
@@ -52,7 +52,7 @@ pub(super) struct LanHost {
|
||||
/// Blocks broken this tick (their sound is a level event for the guests).
|
||||
pub broken: Vec<[f64; 3]>,
|
||||
/// Hurt and death state of the mobs guests see.
|
||||
mob_state: HashMap<u32, (i32, bool)>,
|
||||
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool)>>,
|
||||
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
|
||||
pub mob_players: HashMap<u32, u32>,
|
||||
/// An integrated server's owner (game/integrated.rs).
|
||||
@@ -656,6 +656,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a command changed while the hub was out (`as_guest`), back on
|
||||
/// the hub: `defaultgamemode` run by a guest or the integrated server's owner.
|
||||
pub(super) fn sync_hub_settings(&self, host: &mut LanHost) {
|
||||
host.hub.config.default_game_mode = self.level.mode();
|
||||
}
|
||||
|
||||
/// New command access and game mode forcing settings of the open world.
|
||||
pub(super) fn lan_apply_settings(&mut self, guest_commands: bool, force: bool) {
|
||||
if let Some(server) = &self.integrated {
|
||||
@@ -789,7 +795,19 @@ impl Game<'_> {
|
||||
tick_rate: self.clock.rate(),
|
||||
tick_frozen: self.clock.frozen(),
|
||||
};
|
||||
host.hub.tick(&mut ctx)
|
||||
// A server with several levels runs a pass for each
|
||||
// (`tick_server`): the network is read once, before the first.
|
||||
let pass = self.level_pass;
|
||||
let mut events = if pass.first {
|
||||
host.hub.poll(&mut ctx)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
events.extend(host.hub.tick_level(&mut ctx));
|
||||
if pass.last {
|
||||
host.hub.end_tick();
|
||||
}
|
||||
events
|
||||
};
|
||||
if let Some(state) = &state
|
||||
&& host.host_mode != state.game_mode
|
||||
@@ -830,7 +848,15 @@ impl Game<'_> {
|
||||
if let Some(pos) = guest.menu.as_ref().and_then(|m| m.pos)
|
||||
&& !guest.spectator()
|
||||
{
|
||||
self.container_openers_changed(pos, false);
|
||||
// In the level they were in.
|
||||
let back = self.dim;
|
||||
let theirs = super::Dim::from_id(&guest.dimension);
|
||||
if self.enter_level(Some(&mut host), theirs).is_ok() {
|
||||
self.container_openers_changed(pos, false);
|
||||
}
|
||||
if let Err(e) = self.enter_level(Some(&mut host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
}
|
||||
self.lan_crafting_left(&mut host, &mut guest);
|
||||
self.save_guest(&host, &guest);
|
||||
@@ -883,6 +909,7 @@ impl Game<'_> {
|
||||
let default_mode = host.hub.config.default_game_mode;
|
||||
let force = host.hub.config.force_game_mode;
|
||||
let root = self.world_root.clone();
|
||||
let in_place = host.hub.config.dimension.clone();
|
||||
let owner = host.owner.as_mut();
|
||||
let Some(g) = host.hub.guest_mut(conn) else {
|
||||
return;
|
||||
@@ -905,8 +932,28 @@ impl Game<'_> {
|
||||
g.previous_position = spawn;
|
||||
g.health = 20.0;
|
||||
g.food = 20;
|
||||
g.dimension = super::Dim::Overworld.id().to_owned();
|
||||
}
|
||||
// A server places each player in their own level
|
||||
// (`PlayerList.placeNewPlayer`); the in-frame game has one.
|
||||
let back = self.dim;
|
||||
let level = super::Dim::from_id(&g.dimension);
|
||||
let elsewhere = self.options.dedicated.is_some() && level != back;
|
||||
if !elsewhere {
|
||||
g.dimension = in_place;
|
||||
}
|
||||
self.load_guest_records(host, conn);
|
||||
let theirs;
|
||||
let entities = if elsewhere {
|
||||
if let Err(e) = self.enter_level(Some(host), level) {
|
||||
log::error!("could not open {}: {e}", level.id());
|
||||
return;
|
||||
}
|
||||
theirs = self.lan_entities(&mut host.hub);
|
||||
&theirs[..]
|
||||
} else {
|
||||
entities
|
||||
};
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: state,
|
||||
@@ -920,6 +967,9 @@ impl Game<'_> {
|
||||
tick_frozen: self.clock.frozen(),
|
||||
};
|
||||
host.hub.place_new_player(conn, &mut ctx);
|
||||
if elsewhere && let Err(e) = self.enter_level(Some(host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
}
|
||||
|
||||
/// Level events the game queued, for the guests (`process_level_events`).
|
||||
@@ -974,7 +1024,11 @@ impl Game<'_> {
|
||||
let mut seen = HashMap::new();
|
||||
for m in &self.mobs {
|
||||
let dead = !m.alive();
|
||||
let old = host.mob_state.get(&m.id).copied();
|
||||
let old = host
|
||||
.mob_state
|
||||
.get(&self.dim)
|
||||
.and_then(|s| s.get(&m.id))
|
||||
.copied();
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
if m.hurt_time > old.map_or(0, |o| o.0) && !dead {
|
||||
host.hub.entity_event(m.id as i32, 2, at);
|
||||
@@ -984,7 +1038,7 @@ impl Game<'_> {
|
||||
}
|
||||
seen.insert(m.id, (m.hurt_time, dead));
|
||||
}
|
||||
host.mob_state = seen;
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
}
|
||||
|
||||
/// Keep the chunks around every guest loaded.
|
||||
@@ -993,7 +1047,7 @@ impl Game<'_> {
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed)
|
||||
.filter(|g| host.hub.here(g))
|
||||
// The tracked square's corners and their lit neighbours lie
|
||||
// within (view distance + 2) * √2 of the player's chunk.
|
||||
.map(|g| (g.chunk(), (g.view_distance + 2) * 3 / 2 + 1))
|
||||
|
||||
@@ -54,6 +54,8 @@ pub(super) struct GuestPlay {
|
||||
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
|
||||
pub(super) stats: super::stats::Stats,
|
||||
last_inventory: u64,
|
||||
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
|
||||
pub(super) portal: horizon_client::nether_portal::PortalState,
|
||||
}
|
||||
|
||||
struct ItemRules<'a>(&'a horizon_client::items::Items);
|
||||
@@ -140,13 +142,20 @@ impl Game<'_> {
|
||||
total: g.xp_total,
|
||||
},
|
||||
);
|
||||
let sneak = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
// The host's held keys stay theirs: the guest holds only sneak, and a
|
||||
// screen the guest opens (`keys.clear()`) leaves the host walking.
|
||||
let host_keys = std::mem::take(&mut self.keys);
|
||||
if g.sneaking {
|
||||
self.keys.insert(KeyCode::ShiftLeft);
|
||||
} else {
|
||||
self.keys.remove(&KeyCode::ShiftLeft);
|
||||
}
|
||||
// The window's screens are the host's: a sign, book, command block or
|
||||
// mount menu the guest's action opens is not drawn over the host's
|
||||
// world (#687). The guest's client opens its own.
|
||||
let screen = std::mem::replace(&mut self.screen, Screen::Playing);
|
||||
let host_sign = self.sign_edit.take();
|
||||
let host_book = self.book.take();
|
||||
let host_command_block = self.command_block_screen.take();
|
||||
let host_mount_menu = std::mem::take(&mut self.mount_menu);
|
||||
// The host's crafting grid stays theirs; a crafting table the guest
|
||||
// opens shows as the 3x3 size.
|
||||
let craft_size = std::mem::replace(&mut self.craft_size, 2);
|
||||
@@ -205,11 +214,11 @@ impl Game<'_> {
|
||||
play.swing = std::mem::replace(&mut self.swing, swing);
|
||||
self.container = container;
|
||||
self.screen = screen;
|
||||
if sneak {
|
||||
self.keys.insert(KeyCode::ShiftLeft);
|
||||
} else {
|
||||
self.keys.remove(&KeyCode::ShiftLeft);
|
||||
}
|
||||
self.sign_edit = host_sign;
|
||||
self.book = host_book;
|
||||
self.command_block_screen = host_command_block;
|
||||
self.mount_menu = host_mount_menu;
|
||||
self.keys = host_keys;
|
||||
let g = &mut host.hub.guests[i];
|
||||
std::mem::swap(&mut self.ender_items, &mut g.ender_items);
|
||||
std::mem::swap(&mut self.adv.progress, &mut g.advancements);
|
||||
@@ -282,6 +291,32 @@ impl Game<'_> {
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// `ServerLevel.players()` in turn: the host's own player, then each
|
||||
/// guest in the level in place with their state swapped in (`as_guest`).
|
||||
/// What reaches the players around a point (a beacon's powers, a splash
|
||||
/// potion, a warden's darkness, an elder guardian's curse) lands on each
|
||||
/// of them, not only on the host. A server has no player of its own.
|
||||
/// Inside a guest's action (the hub taken) only that player is in place.
|
||||
pub(super) fn for_each_player(&mut self, mut f: impl FnMut(&mut Self)) {
|
||||
if self.player.is_some() {
|
||||
f(self);
|
||||
}
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
self.as_guest(&mut host, conn, &mut f);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
|
||||
/// The game mode fields without `set_mode`'s side effects on the host.
|
||||
fn set_mode_raw(&mut self, id: i32) {
|
||||
use horizon_client::command::args::GameMode;
|
||||
@@ -663,6 +698,7 @@ impl Game<'_> {
|
||||
{
|
||||
self.guest_suggestions(host, conn, &payload);
|
||||
}
|
||||
S::PlaceRecipe(p) => self.lan_place_recipe(host, conn, &p),
|
||||
S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn),
|
||||
// ServerStatsCounter.sendStats.
|
||||
S::ClientCommand(c) if c.action == 1 => {
|
||||
@@ -811,6 +847,7 @@ impl Game<'_> {
|
||||
g.ui.registries.permission = console;
|
||||
g.chat_capture.take().unwrap_or_default()
|
||||
});
|
||||
self.sync_hub_settings(host);
|
||||
for line in lines.unwrap_or_default() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
@@ -851,6 +888,7 @@ impl Game<'_> {
|
||||
.map(|t| horizon_client::lan::data::component_nbt(&t.component()))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
self.sync_hub_settings(host);
|
||||
for content in lines.unwrap_or_default() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
@@ -910,7 +948,18 @@ impl Game<'_> {
|
||||
if !host.hub.guests[i].dead {
|
||||
return;
|
||||
}
|
||||
self.respawn_level(host);
|
||||
// PlayerList.respawn: a server's player comes back in the Overworld,
|
||||
// where the world spawn is (respawn anchors and beds aside).
|
||||
let back = self.dim;
|
||||
let away = self.options.dedicated.is_some()
|
||||
&& host.hub.guests[i].dimension != super::Dim::Overworld.id();
|
||||
if away {
|
||||
self.server_trips.retain(|t| !t.is(conn));
|
||||
if let Err(e) = self.enter_level(Some(host), super::Dim::Overworld) {
|
||||
log::error!("could not open the Overworld: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
g.dead = false;
|
||||
g.health = 20.0;
|
||||
@@ -946,6 +995,9 @@ impl Game<'_> {
|
||||
tick_frozen: self.clock.frozen(),
|
||||
};
|
||||
host.hub.respawn(i, spawn, &ctx);
|
||||
if away && let Err(e) = self.enter_level(Some(host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
}
|
||||
|
||||
/// Per tick, for every guest: dig, use items, pick up drops, die.
|
||||
@@ -956,7 +1008,7 @@ impl Game<'_> {
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed && g.loaded)
|
||||
.filter(|g| host.hub.here(g) && g.loaded)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
@@ -1348,6 +1400,9 @@ impl Game<'_> {
|
||||
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
|
||||
continue;
|
||||
};
|
||||
if !host.hub.here(g) {
|
||||
continue;
|
||||
}
|
||||
let (id, at) = (g.entity_id, g.position);
|
||||
let play = host.play.get_mut(&conn).expect("play");
|
||||
let swinging = play.swing.swinging() && play.swing.ticks == 0;
|
||||
|
||||
@@ -225,6 +225,86 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.handlePlaceRecipe: the guest's recipe
|
||||
/// book places a known recipe in their inventory grid or crafting table
|
||||
/// (`ServerPlaceRecipe`), and answers with a ghost recipe when the items
|
||||
/// are missing.
|
||||
pub(super) fn lan_place_recipe(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
p: &horizon_protocol::packets::recipes::PlaceRecipe,
|
||||
) {
|
||||
use horizon_client::menu::place::PostPlace;
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let g = &host.hub.guests[gi];
|
||||
if g.spectator() {
|
||||
return;
|
||||
}
|
||||
let Some(id) = self
|
||||
.res
|
||||
.stations
|
||||
.recipe_index
|
||||
.recipe_of_display(p.recipe)
|
||||
.map(str::to_owned)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// An unknown or impossible recipe is refused outright.
|
||||
if !g.recipe_book.knows(&id)
|
||||
|| self
|
||||
.res
|
||||
.recipes
|
||||
.placement(&id)
|
||||
.is_none_or(|placement| placement.impossible())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let creative = g.creative();
|
||||
let placed = if p.container == 0 {
|
||||
let crafting = host.play.entry(conn).or_default();
|
||||
let mut menu = crafting
|
||||
.crafting
|
||||
.inventory
|
||||
.take()
|
||||
.unwrap_or_else(|| Box::new(Menu::new(Kind::Inventory)));
|
||||
let grid = host.hub.guests[gi].craft_grid.clone();
|
||||
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
for (k, s) in grid.into_iter().enumerate() {
|
||||
menu.set_slot(env, 1 + k, s);
|
||||
}
|
||||
menu.place_recipe(env, &id, p.use_max, creative, None)
|
||||
});
|
||||
let g = &mut host.hub.guests[gi];
|
||||
for k in 0..4 {
|
||||
g.craft_grid[k] = menu.slot(1 + k).cloned();
|
||||
}
|
||||
g.craft_result = menu.slot(0).cloned();
|
||||
host.play.entry(conn).or_default().crafting.inventory = Some(menu);
|
||||
host.hub.send_inventory(gi);
|
||||
placed
|
||||
} else {
|
||||
let Some((window, state, mut menu)) = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|play| play.crafting.table.take_if(|(w, _, _)| *w == p.container))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
menu.place_recipe(env, &id, p.use_max, creative, None)
|
||||
});
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu));
|
||||
self.lan_send_table_content(host, conn);
|
||||
placed
|
||||
};
|
||||
if placed == PostPlace::Ghost {
|
||||
host.hub.send_ghost_recipe(conn, p.container, &id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
|
||||
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32) {
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
|
||||
@@ -92,7 +92,7 @@ impl Game<'_> {
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed && g.loaded && !g.dead)
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| {
|
||||
let moved = [0, 1, 2].map(|k| g.position[k] - g.previous_position[k]);
|
||||
(g.conn, g.uuid, moved)
|
||||
@@ -138,7 +138,7 @@ impl Game<'_> {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed && g.loaded && !g.dead && !g.spectator())
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
|
||||
.map(|g| (Who::Mob(g.entity_id as u32), guest_bounds(g)))
|
||||
.collect()
|
||||
}
|
||||
@@ -209,9 +209,12 @@ pub(super) struct Present {
|
||||
impl Game<'_> {
|
||||
/// Every guest in the world (`ServerLevel.getPlayers` past the host).
|
||||
pub(super) fn lan_present(&self) -> Vec<Present> {
|
||||
let guests = self.lan.iter().flat_map(|h| &h.hub.guests);
|
||||
let guests = self
|
||||
.lan
|
||||
.iter()
|
||||
.flat_map(|h| h.hub.guests.iter().filter(|g| h.hub.here(g)));
|
||||
guests
|
||||
.filter(|g| g.placed && g.loaded)
|
||||
.filter(|g| g.loaded)
|
||||
.map(|g| {
|
||||
let [x, y, z] = g.position;
|
||||
Present {
|
||||
|
||||
@@ -32,7 +32,7 @@ impl Game<'_> {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed && g.loaded && !g.dead && !g.spectator())
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
|
||||
.filter_map(|g| Some((g.conn, ray_box(eye, dir, reach, guest_bounds(g))?)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
}
|
||||
@@ -64,7 +64,7 @@ impl Game<'_> {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.entity_id == id && g.conn != conn && g.placed && !g.dead)
|
||||
.find(|g| g.entity_id == id && g.conn != conn && host.hub.here(g) && !g.dead)
|
||||
.map(|g| Victim::Guest(g.conn))
|
||||
}
|
||||
|
||||
|
||||
@@ -372,7 +372,7 @@ impl Game<'_> {
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.find(|g| g.conn == conn && host.hub.here(g))
|
||||
.map(|g| uuid_words(g.uuid))
|
||||
else {
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//! A server's levels, one per dimension (`MinecraftServer.levels`).
|
||||
//!
|
||||
//! The game's tick code reads one level: the one in place, held in `Game`'s
|
||||
//! own fields (the world, its streaming and save, block ticks, entities).
|
||||
//! A server (the dedicated server and single player's server thread) keeps
|
||||
//! the other dimensions' levels here and ticks each one that has players by
|
||||
//! swapping it into place (`enter_level`), the way `as_guest` swaps a player
|
||||
//! in, so the tick code runs unchanged, one level at a time. The in-frame
|
||||
//! game holds one level, which follows its player (`enter_dimension`).
|
||||
//!
|
||||
//! Shared by every level, as vanilla's derived levels share the
|
||||
//! Overworld's level data: the game time, the day time and the weather, the
|
||||
//! game rules, the scoreboard, the maps and the entities waiting to arrive
|
||||
//! through a portal.
|
||||
|
||||
use super::{
|
||||
Dim, ExperienceOrb, FallingBlock, Game, ItemEntity, Mob, boats, commands,
|
||||
creaking::CreakingState, dragon, fireworks, lan::LanHost, lightning, machines, minecarts,
|
||||
ominous_item_spawner, persist, raids::Raiding, sculk::SculkState, tnt, trident,
|
||||
};
|
||||
use horizon_client::{
|
||||
Result, block_entity_anim::BlockEntityAnims, random_tick_sections::RandomTickSections,
|
||||
save::SaveDir, streaming::Streaming, tick_chunk_membership::TickChunkMembership, world::World,
|
||||
};
|
||||
use horizon_world::level::{LevelEvent, Ticks};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// Server ticks a level other than the Overworld stays loaded with no player
|
||||
/// in it, before it is saved and closed (vanilla unloads a level's chunks
|
||||
/// once no ticket holds them; the Overworld stays loaded).
|
||||
const IDLE_TICKS: u32 = 300;
|
||||
|
||||
/// A level that is not in place: every per-level field of `Game`.
|
||||
pub(super) struct Parked {
|
||||
world: World,
|
||||
streaming: Streaming,
|
||||
save: Arc<SaveDir>,
|
||||
block_ticks: Ticks,
|
||||
tick_chunks: TickChunkMembership,
|
||||
random_tick_sections: RandomTickSections,
|
||||
level_events: Vec<LevelEvent>,
|
||||
falling: Vec<FallingBlock>,
|
||||
paintings: Vec<horizon_client::entities::Painting>,
|
||||
item_frames: Vec<horizon_client::entities::ItemFrame>,
|
||||
armor_stands: Vec<horizon_client::entities::ArmorStand>,
|
||||
others: Vec<commands::OtherEntity>,
|
||||
boats: Vec<boats::Boat>,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
|
||||
bolts: Vec<lightning::Bolt>,
|
||||
crystals: Vec<dragon::Crystal>,
|
||||
dragon: Option<dragon::Dragon>,
|
||||
dragon_fireballs: Vec<dragon::DragonFireball>,
|
||||
breath_clouds: Vec<dragon::Breath>,
|
||||
tridents: Vec<trident::ThrownTrident>,
|
||||
rockets: Vec<fireworks::Rocket>,
|
||||
ominous_spawners: Vec<ominous_item_spawner::OminousItemSpawner>,
|
||||
tnts: Vec<tnt::PrimedTnt>,
|
||||
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
|
||||
mobs: Vec<Mob>,
|
||||
projectiles: Vec<horizon_client::projectiles::Projectile>,
|
||||
dropped: Vec<ItemEntity>,
|
||||
orbs: Vec<ExperienceOrb>,
|
||||
persist: persist::EntityMemory,
|
||||
lectern_pulses: Vec<((i32, i32, i32), i32)>,
|
||||
lid_openers: HashMap<(i32, i32, i32), i32>,
|
||||
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u16)>,
|
||||
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
|
||||
/// Only the per-level parts of these are used (`swap_level`).
|
||||
anims: BlockEntityAnims,
|
||||
raiding: Raiding,
|
||||
sculk: SculkState,
|
||||
creaking: CreakingState,
|
||||
/// Server ticks since a player was last in the level.
|
||||
idle: u32,
|
||||
}
|
||||
|
||||
/// Which passes of the server tick the level in place runs: the first
|
||||
/// reads the network (`Hub::poll`), the last ends the tick for every player
|
||||
/// (`Hub::end_tick`). A tick with one level is both.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Pass {
|
||||
pub first: bool,
|
||||
pub last: bool,
|
||||
}
|
||||
|
||||
impl Default for Pass {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
first: true,
|
||||
last: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The levels not in place, by dimension.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Levels {
|
||||
parked: HashMap<Dim, Parked>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A new level for `dim`, opened from the save (`MinecraftServer.createLevels`).
|
||||
fn open_level(&self, dim: Dim) -> Result<Parked> {
|
||||
let (streaming, save) =
|
||||
self.make_streaming(&self.world_root.clone(), dim, self.level.seed)?;
|
||||
let mut world = World::new(self.res.facts.air, self.level.seed);
|
||||
world.fast_lava = dim == Dim::Nether;
|
||||
Ok(Parked {
|
||||
world,
|
||||
streaming,
|
||||
save,
|
||||
block_ticks: Ticks::new(self.level.seed as i32),
|
||||
tick_chunks: Default::default(),
|
||||
random_tick_sections: Default::default(),
|
||||
level_events: Vec::new(),
|
||||
falling: Vec::new(),
|
||||
paintings: Vec::new(),
|
||||
item_frames: Vec::new(),
|
||||
armor_stands: Vec::new(),
|
||||
others: Vec::new(),
|
||||
boats: Vec::new(),
|
||||
carts: Vec::new(),
|
||||
jukebox_sounds: HashMap::new(),
|
||||
bolts: Vec::new(),
|
||||
crystals: Vec::new(),
|
||||
dragon: None,
|
||||
dragon_fireballs: Vec::new(),
|
||||
breath_clouds: Vec::new(),
|
||||
tridents: Vec::new(),
|
||||
rockets: Vec::new(),
|
||||
ominous_spawners: Vec::new(),
|
||||
tnts: Vec::new(),
|
||||
hoppers: HashMap::new(),
|
||||
mobs: Vec::new(),
|
||||
projectiles: Vec::new(),
|
||||
dropped: Vec::new(),
|
||||
orbs: Vec::new(),
|
||||
persist: Default::default(),
|
||||
lectern_pulses: Vec::new(),
|
||||
lid_openers: HashMap::new(),
|
||||
lan_events: Vec::new(),
|
||||
lan_block_events: Vec::new(),
|
||||
lan_entity_events: Vec::new(),
|
||||
anims: Default::default(),
|
||||
raiding: Default::default(),
|
||||
sculk: Default::default(),
|
||||
creaking: Default::default(),
|
||||
idle: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Exchange the level in place with `p`.
|
||||
fn swap_level(&mut self, p: &mut Parked) {
|
||||
use std::mem::swap;
|
||||
swap(&mut self.world, &mut p.world);
|
||||
swap(&mut self.streaming, &mut p.streaming);
|
||||
swap(&mut self.save, &mut p.save);
|
||||
swap(&mut self.block_ticks, &mut p.block_ticks);
|
||||
swap(&mut self.tick_chunks, &mut p.tick_chunks);
|
||||
swap(&mut self.random_tick_sections, &mut p.random_tick_sections);
|
||||
swap(&mut self.level_events, &mut p.level_events);
|
||||
swap(&mut self.falling, &mut p.falling);
|
||||
swap(&mut self.paintings, &mut p.paintings);
|
||||
swap(&mut self.item_frames, &mut p.item_frames);
|
||||
swap(&mut self.armor_stands, &mut p.armor_stands);
|
||||
swap(&mut self.others, &mut p.others);
|
||||
swap(&mut self.boats, &mut p.boats);
|
||||
swap(&mut self.carts, &mut p.carts);
|
||||
swap(&mut self.jukebox_sounds, &mut p.jukebox_sounds);
|
||||
swap(&mut self.bolts, &mut p.bolts);
|
||||
swap(&mut self.crystals, &mut p.crystals);
|
||||
swap(&mut self.dragon, &mut p.dragon);
|
||||
swap(&mut self.dragon_fireballs, &mut p.dragon_fireballs);
|
||||
swap(&mut self.breath_clouds, &mut p.breath_clouds);
|
||||
swap(&mut self.tridents, &mut p.tridents);
|
||||
swap(&mut self.rockets, &mut p.rockets);
|
||||
swap(&mut self.ominous_spawners, &mut p.ominous_spawners);
|
||||
swap(&mut self.tnts, &mut p.tnts);
|
||||
swap(&mut self.hoppers, &mut p.hoppers);
|
||||
swap(&mut self.mobs, &mut p.mobs);
|
||||
swap(&mut self.creatures.projectiles, &mut p.projectiles);
|
||||
swap(&mut self.dropped, &mut p.dropped);
|
||||
swap(&mut self.orbs, &mut p.orbs);
|
||||
swap(&mut self.persist, &mut p.persist);
|
||||
swap(&mut self.lectern_pulses, &mut p.lectern_pulses);
|
||||
swap(&mut self.lid_openers, &mut p.lid_openers);
|
||||
swap(&mut self.lan_events, &mut p.lan_events);
|
||||
swap(&mut self.lan_block_events, &mut p.lan_block_events);
|
||||
swap(&mut self.lan_entity_events, &mut p.lan_entity_events);
|
||||
let (a, b) = (&mut self.be_anims, &mut p.anims);
|
||||
swap(&mut a.chests, &mut b.chests);
|
||||
swap(&mut a.books, &mut b.books);
|
||||
swap(&mut a.shulkers, &mut b.shulkers);
|
||||
swap(&mut a.bells, &mut b.bells);
|
||||
swap(&mut a.conduits, &mut b.conduits);
|
||||
swap(&mut a.skulls, &mut b.skulls);
|
||||
swap(&mut a.pots, &mut b.pots);
|
||||
swap(&mut a.gateways, &mut b.gateways);
|
||||
swap(&mut a.gateway_servers, &mut b.gateway_servers);
|
||||
self.raiding.swap_level(&mut p.raiding);
|
||||
self.sculk.swap_level(&mut p.sculk);
|
||||
self.creaking.swap_level(&mut p.creaking);
|
||||
// What only the client draws starts over.
|
||||
self.particles.clear();
|
||||
self.pickups.clear();
|
||||
}
|
||||
|
||||
/// Put `target`'s level in place, opening it first if it is not loaded.
|
||||
/// The level that was in place is kept with the others; the shared
|
||||
/// clock and weather go with the swap. `host` is the hub when the caller
|
||||
/// holds it (`self.lan` is taken during the network tick).
|
||||
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
|
||||
// The in-frame game keeps its one level (`enter_dimension`).
|
||||
if self.options.dedicated.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if target != self.dim {
|
||||
let (mut p, opened) = match self.levels.parked.remove(&target) {
|
||||
Some(p) => (p, false),
|
||||
None => (self.open_level(target)?, true),
|
||||
};
|
||||
p.world.game_time = self.world.game_time;
|
||||
p.world.day_time = self.world.day_time;
|
||||
p.world.weather = self.world.weather;
|
||||
// The sculk tables' watched blocks are built once per game.
|
||||
p.world.watched = std::mem::take(&mut self.world.watched);
|
||||
self.swap_level(&mut p);
|
||||
p.idle = 0;
|
||||
let left = std::mem::replace(&mut self.dim, target);
|
||||
self.levels.parked.insert(left, p);
|
||||
if opened {
|
||||
// The new streaming's radius and meshing options.
|
||||
self.apply_settings();
|
||||
}
|
||||
}
|
||||
let host = match host {
|
||||
Some(h) => Some(h),
|
||||
None => self.lan.as_mut(),
|
||||
};
|
||||
if let Some(h) = host {
|
||||
h.hub.set_dimension(target.id());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One server tick (`MinecraftServer.tickServer`): each level with
|
||||
/// players in turn (`tickChildren`), its chunks streamed and loaded and
|
||||
/// its world ticked. The first pass reads the network and runs the
|
||||
/// tick's shared parts (the clock, the weather, functions, the autosave);
|
||||
/// the next ones only their level. While `paused`, only the connections
|
||||
/// run.
|
||||
pub(super) fn tick_server(&mut self, paused: bool) -> Result<()> {
|
||||
let dims = self.levels_to_tick();
|
||||
let n = dims.len();
|
||||
let mut result = Ok(());
|
||||
for (k, dim) in dims.into_iter().enumerate() {
|
||||
if let Err(e) = self.enter_level(None, dim) {
|
||||
result = Err(e);
|
||||
break;
|
||||
}
|
||||
self.level_pass = Pass {
|
||||
first: k == 0,
|
||||
last: k + 1 == n,
|
||||
};
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
result = if paused {
|
||||
self.tick_lan();
|
||||
Ok(())
|
||||
} else if k == 0 {
|
||||
self.tick()
|
||||
} else {
|
||||
let running = self.clock.world_runs();
|
||||
if running {
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
self.tick_world(running)
|
||||
};
|
||||
if result.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.level_pass = Pass::default();
|
||||
result?;
|
||||
self.close_idle_levels()
|
||||
}
|
||||
|
||||
/// The levels this server tick runs, in `MinecraftServer.levels` order
|
||||
/// (the Overworld, the Nether, the End): every level with a player in
|
||||
/// it, or the Overworld while nobody is online.
|
||||
pub(super) fn levels_to_tick(&self) -> Vec<Dim> {
|
||||
let guests = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.map_or(&[][..], |h| h.hub.guests.as_slice());
|
||||
let mut dims: Vec<Dim> = [Dim::Overworld, Dim::Nether, Dim::End]
|
||||
.into_iter()
|
||||
.filter(|d| guests.iter().any(|g| g.dimension == d.id()))
|
||||
.collect();
|
||||
if dims.is_empty() {
|
||||
dims.push(Dim::Overworld);
|
||||
}
|
||||
dims
|
||||
}
|
||||
|
||||
/// Count the parked levels' empty ticks, and save and close the ones
|
||||
/// (other than the Overworld) nobody has been in for a while.
|
||||
pub(super) fn close_idle_levels(&mut self) -> Result<()> {
|
||||
let occupied = self.levels_to_tick();
|
||||
let mut closing = Vec::new();
|
||||
for (dim, p) in &mut self.levels.parked {
|
||||
if occupied.contains(dim) {
|
||||
p.idle = 0;
|
||||
continue;
|
||||
}
|
||||
p.idle += 1;
|
||||
if *dim != Dim::Overworld && p.idle >= IDLE_TICKS {
|
||||
closing.push(*dim);
|
||||
}
|
||||
}
|
||||
if closing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let back = self.dim;
|
||||
for dim in closing {
|
||||
self.enter_level(None, dim)?;
|
||||
self.save_level(true)?;
|
||||
self.enter_level(None, back)?;
|
||||
self.levels.parked.remove(&dim);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save every parked level (`MinecraftServer.saveAllChunks`); the level
|
||||
/// in place is the caller's to save.
|
||||
pub(super) fn save_parked_levels(&mut self, wait: bool) -> Result<()> {
|
||||
if self.levels.parked.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let back = self.dim;
|
||||
let dims: Vec<Dim> = self.levels.parked.keys().copied().collect();
|
||||
for dim in dims {
|
||||
self.enter_level(None, dim)?;
|
||||
self.save_level(wait)?;
|
||||
}
|
||||
self.enter_level(None, back)
|
||||
}
|
||||
}
|
||||
@@ -365,13 +365,6 @@ impl Game<'_> {
|
||||
self.close_book();
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(remote) = self.remote.as_mut()
|
||||
&& remote.chat_input.is_some()
|
||||
{
|
||||
remote.chat_input = None;
|
||||
self.capture(true);
|
||||
return Ok(());
|
||||
}
|
||||
self.key_pressed(KeyCode::Escape)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ use winit::{
|
||||
dpi::PhysicalPosition,
|
||||
event::{DeviceId, ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent},
|
||||
event_loop::ActiveEventLoop,
|
||||
keyboard::{KeyCode, NamedKey},
|
||||
keyboard::KeyCode,
|
||||
};
|
||||
|
||||
/// D-pad auto-repeat: the first step at once, the next after a pause.
|
||||
@@ -859,7 +859,6 @@ impl Game<'_> {
|
||||
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|
||||
|| self.recipe_search_focused()
|
||||
|| self.station_typing()
|
||||
|| self.remote_chat_open()
|
||||
}
|
||||
|
||||
/// Open the on-screen keyboard over the focused field: Horizon's, or
|
||||
@@ -890,7 +889,7 @@ impl Game<'_> {
|
||||
&& self.pad.in_use
|
||||
&& menu.open_polls >= 3
|
||||
&& self.pad_focus_mode()
|
||||
&& (self.screen == Screen::Chat || self.sign_edit.is_some() || self.remote_chat_open())
|
||||
&& (self.screen == Screen::Chat || self.sign_edit.is_some())
|
||||
{
|
||||
self.pad.menu.osk_dismissed = true;
|
||||
self.pad_open_keyboard();
|
||||
@@ -901,10 +900,10 @@ impl Game<'_> {
|
||||
/// the field that has focus.
|
||||
fn pad_osk_output(&mut self, out: osk::Output) {
|
||||
use osk::Output;
|
||||
let (key, named, text) = match &out {
|
||||
Output::Text(t) => (None, None, Some(t.as_str())),
|
||||
Output::Backspace => (Some(KeyCode::Backspace), Some(NamedKey::Backspace), None),
|
||||
Output::Enter => (Some(KeyCode::Enter), Some(NamedKey::Enter), None),
|
||||
let (key, text) = match &out {
|
||||
Output::Text(t) => (None, Some(t.as_str())),
|
||||
Output::Backspace => (Some(KeyCode::Backspace), None),
|
||||
Output::Enter => (Some(KeyCode::Enter), None),
|
||||
Output::Close => {
|
||||
self.pad.menu.osk = None;
|
||||
self.pad.menu.osk_dismissed = true;
|
||||
@@ -933,8 +932,6 @@ impl Game<'_> {
|
||||
if key != Some(KeyCode::Enter) {
|
||||
self.station_text(text, backspace);
|
||||
}
|
||||
} else if self.remote_chat_open() {
|
||||
self.remote_chat_input(named, text);
|
||||
} else if self.screen == Screen::WorldOptions {
|
||||
if key != Some(KeyCode::Enter) {
|
||||
self.world_options_text(text);
|
||||
@@ -985,8 +982,7 @@ impl Game<'_> {
|
||||
.menu
|
||||
.focus
|
||||
.is_some_and(|f| f.center().1 > batch.height / 2.0)
|
||||
|| self.screen == Screen::Chat
|
||||
|| self.remote_chat_open();
|
||||
|| self.screen == Screen::Chat;
|
||||
let mut ui = horizon_client::ui_theme::Ui::new(&self.atlas, batch, self.cursor);
|
||||
keyboard.draw(&mut ui, at_top, self.ui.options.pad_style);
|
||||
ui.finish();
|
||||
|
||||
@@ -467,6 +467,41 @@ impl Game<'_> {
|
||||
self.splash(&stack, at);
|
||||
}
|
||||
}
|
||||
/// A splash potion's effects on the player in place, by their distance.
|
||||
fn splash_on_player(
|
||||
&mut self,
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
at: Vec3,
|
||||
scale: f64,
|
||||
) {
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let center = Vec3::new(
|
||||
p.position.x,
|
||||
p.position.y + f64::from(p.dimensions.height) / 2.0,
|
||||
p.position.z,
|
||||
);
|
||||
let d2 =
|
||||
(center.x - at.x).powi(2) + (center.y - at.y).powi(2) + (center.z - at.z).powi(2);
|
||||
if d2 < 16.0 && (center.y - at.y).abs() <= 2.0 + f64::from(p.dimensions.height) {
|
||||
let intensity = 1.0 - d2.sqrt() / 4.0;
|
||||
for (id, duration, amp) in effects {
|
||||
let instant = brewing::effect(id).is_some_and(|e| e.instant);
|
||||
if instant {
|
||||
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
|
||||
self.apply_effect_action(a);
|
||||
}
|
||||
} else {
|
||||
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
|
||||
if d > 20
|
||||
&& let Some(e) = brewing::effect(id)
|
||||
{
|
||||
self.potions.effects.add(EffectInstance::new(e.id, d, *amp));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
|
||||
fn splash(&mut self, stack: &ItemStack, at: Vec3) {
|
||||
if &*stack.item == "minecraft:experience_bottle" {
|
||||
@@ -527,34 +562,9 @@ impl Game<'_> {
|
||||
let Some(def) = stack.potion().and_then(brewing::potion) else {
|
||||
return;
|
||||
};
|
||||
// The player.
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let center = Vec3::new(
|
||||
p.position.x,
|
||||
p.position.y + f64::from(p.dimensions.height) / 2.0,
|
||||
p.position.z,
|
||||
);
|
||||
let d2 =
|
||||
(center.x - at.x).powi(2) + (center.y - at.y).powi(2) + (center.z - at.z).powi(2);
|
||||
if d2 < 16.0 && (center.y - at.y).abs() <= 2.0 + f64::from(p.dimensions.height) {
|
||||
let intensity = 1.0 - d2.sqrt() / 4.0;
|
||||
for (id, duration, amp) in def.effects {
|
||||
let instant = brewing::effect(id).is_some_and(|e| e.instant);
|
||||
if instant {
|
||||
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
|
||||
self.apply_effect_action(a);
|
||||
}
|
||||
} else {
|
||||
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
|
||||
if d > 20
|
||||
&& let Some(e) = brewing::effect(id)
|
||||
{
|
||||
self.potions.effects.add(EffectInstance::new(e.id, d, *amp));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every player in range (`ThrownSplashPotion.applySplash` over
|
||||
// `LivingEntity`s, the players among them).
|
||||
self.for_each_player(|g| g.splash_on_player(def.effects, at, scale));
|
||||
// Mobs take the instant effects (undead invert healing and harming).
|
||||
let undead = &self.res.stations.enchantments.entity_tags;
|
||||
let mut hits = Vec::new();
|
||||
|
||||
@@ -39,6 +39,17 @@ pub(super) struct Raiding {
|
||||
homes: HashMap<i32, (i64, std::sync::Arc<Vec<[i32; 3]>>)>,
|
||||
}
|
||||
|
||||
impl Raiding {
|
||||
/// Exchange the level-held parts (`ServerLevel.getRaids`) with another
|
||||
/// level's (game/levels.rs).
|
||||
pub(super) fn swap_level(&mut self, other: &mut Self) {
|
||||
std::mem::swap(&mut self.raids, &mut other.raids);
|
||||
std::mem::swap(&mut self.loaded, &mut other.loaded);
|
||||
std::mem::swap(&mut self.bar_hurts, &mut other.bar_hurts);
|
||||
std::mem::swap(&mut self.homes, &mut other.homes);
|
||||
}
|
||||
}
|
||||
|
||||
/// `SectionPos` of a block position.
|
||||
pub(super) fn section(p: [i32; 3]) -> (i32, i32, i32) {
|
||||
(p[0] >> 4, p[1] >> 4, p[2] >> 4)
|
||||
@@ -613,17 +624,17 @@ impl Game<'_> {
|
||||
raider::state_mut(m).ticks_outside_raid = ticks;
|
||||
}
|
||||
}
|
||||
Action::Victory { heroes, amplifier } => {
|
||||
let me = self.level.player_uuid;
|
||||
if me.is_some_and(|u| heroes.contains(&u)) && !self.spectator() {
|
||||
// Raid.tick on a victory: each hero who is still alive.
|
||||
Action::Victory { heroes, amplifier } => self.for_each_player(|g| {
|
||||
if heroes.contains(&g.acting_uuid()) && !g.spectator() {
|
||||
let mut effect =
|
||||
EffectInstance::new("hero_of_the_village", raid::HERO_DURATION, amplifier);
|
||||
effect.visible = false;
|
||||
self.potions.effects.add(effect);
|
||||
self.stats.custom("raid_win", 1);
|
||||
self.trigger_player("minecraft:hero_of_the_village");
|
||||
g.potions.effects.add(effect);
|
||||
g.stats.custom("raid_win", 1);
|
||||
g.trigger_player("minecraft:hero_of_the_village");
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// `Raid.playSound`: the horn, 13 blocks away from the player toward the
|
||||
|
||||
@@ -110,7 +110,7 @@ fn sprite(name: &str) -> String {
|
||||
impl Game<'_> {
|
||||
/// The book the open screen has, if any.
|
||||
fn active_book(&self) -> Option<BookType> {
|
||||
if self.screen != Screen::Inventory || self.remote.is_some() || self.station.is_some() {
|
||||
if self.screen != Screen::Inventory || self.station.is_some() {
|
||||
return None;
|
||||
}
|
||||
if self.mount_active() {
|
||||
@@ -262,6 +262,33 @@ impl Game<'_> {
|
||||
/// The items the book counts (`RecipeBookComponent.updateStackedContents`):
|
||||
/// the inventory and the menu's crafting slots, as the menu engine counts them.
|
||||
fn book_available(&mut self, _book: BookType) -> Stacked {
|
||||
if self.remote.is_some() {
|
||||
// A server's menu: the slots it last sent, counted the same way.
|
||||
use horizon_client::menu::{Env, Kind, Menu};
|
||||
let table = self.craft_size == 3;
|
||||
let grid: Vec<_> = (0..self.craft_size * self.craft_size)
|
||||
.map(|k| self.slot_mut(100 + k).clone())
|
||||
.collect();
|
||||
let mut menu = Menu::new(if table {
|
||||
Kind::Crafting
|
||||
} else {
|
||||
Kind::Inventory
|
||||
});
|
||||
let env = Env {
|
||||
items: &self.res.items,
|
||||
recipes: &self.res.recipes,
|
||||
brewing: &self.res.stations.brewing,
|
||||
banners: &self.res.stations.banners,
|
||||
trade: None,
|
||||
};
|
||||
for i in 0..36 {
|
||||
menu.set_player_item(i, self.inventory[i].clone());
|
||||
}
|
||||
for (k, stack) in grid.into_iter().enumerate() {
|
||||
menu.set_slot(&env, 1 + k, stack);
|
||||
}
|
||||
return menu.stacked_contents(&env);
|
||||
}
|
||||
let mut out = Stacked::default();
|
||||
let stock = &mut out;
|
||||
self.menu_session(|menu, env, _| {
|
||||
@@ -274,6 +301,10 @@ impl Game<'_> {
|
||||
/// replacing batch (vanilla's server iterates an identity hash set; this
|
||||
/// one goes in id order), then each unlock and removal.
|
||||
fn sync_client_book(&mut self) {
|
||||
// On a server the book is the server's packets' (remote_recipe_book_*).
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
let known = &self.level.recipe_book.recipes;
|
||||
let index = &self.res.stations.recipe_index;
|
||||
let ui = &mut self.recipe_ui;
|
||||
@@ -660,6 +691,10 @@ impl Game<'_> {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// How many displays the client's book holds.
|
||||
pub(super) fn recipe_book_known(&self) -> usize {
|
||||
self.recipe_ui.client.displays().len()
|
||||
}
|
||||
/// The recipe book's buttons a controller can focus: the book button,
|
||||
/// recipes, page arrows, the craftable filter and the tabs (GUI pixels;
|
||||
/// the search box registers itself as a field).
|
||||
@@ -800,6 +835,18 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.remote.is_some() {
|
||||
// The server places it and answers with the slots, or a ghost.
|
||||
let container = self.remote_menu_id();
|
||||
self.send(horizon_protocol::packets::play::Serverbound::PlaceRecipe(
|
||||
horizon_protocol::packets::recipes::PlaceRecipe {
|
||||
container,
|
||||
recipe: entry.display_id,
|
||||
use_max,
|
||||
},
|
||||
));
|
||||
return;
|
||||
}
|
||||
let creative = self.creative;
|
||||
let mut post = None;
|
||||
let answer = &mut post;
|
||||
@@ -828,6 +875,80 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- a server's recipe book ----
|
||||
|
||||
/// ClientboundRecipeBookAddPacket (`ClientRecipeBook.add`): the
|
||||
/// displays join the book, highlighted when new, and the recipes whose
|
||||
/// entries ask for it show the toast.
|
||||
pub(super) fn remote_recipe_book_add(
|
||||
&mut self,
|
||||
p: horizon_protocol::packets::recipes::RecipeBookAdd,
|
||||
) {
|
||||
let ui = &mut self.recipe_ui;
|
||||
if p.replace {
|
||||
ui.client.clear();
|
||||
}
|
||||
let mut toast = Vec::new();
|
||||
for e in &p.entries {
|
||||
ui.client.add(e.id);
|
||||
if e.flags & 2 != 0 {
|
||||
ui.client.highlight.insert(e.id);
|
||||
}
|
||||
if e.flags & 1 != 0
|
||||
&& let Some(id) = self.res.stations.recipe_index.recipe_of_display(e.id)
|
||||
&& !toast.iter().any(|t| t == id)
|
||||
{
|
||||
toast.push(id.to_owned());
|
||||
}
|
||||
}
|
||||
// A local world opened later syncs its book from scratch.
|
||||
ui.client_known = None;
|
||||
ui.client_version += 1;
|
||||
self.recipe_toast(&toast);
|
||||
}
|
||||
/// ClientboundRecipeBookRemovePacket.
|
||||
pub(super) fn remote_recipe_book_remove(
|
||||
&mut self,
|
||||
p: horizon_protocol::packets::recipes::RecipeBookRemove,
|
||||
) {
|
||||
for id in p.ids {
|
||||
self.recipe_ui.client.remove(id);
|
||||
}
|
||||
self.recipe_ui.client_version += 1;
|
||||
}
|
||||
/// ClientboundRecipeBookSettingsPacket: each book's open and filter flags.
|
||||
pub(super) fn remote_recipe_book_settings(
|
||||
&mut self,
|
||||
p: horizon_protocol::packets::recipes::RecipeBookSettings,
|
||||
) {
|
||||
for (i, (open, filtering)) in p.books.into_iter().enumerate() {
|
||||
self.level.recipe_book.open[i] = open;
|
||||
self.level.recipe_book.filtering[i] = filtering;
|
||||
}
|
||||
}
|
||||
/// ClientboundPlaceGhostRecipePacket: the recipe last placed, faded into
|
||||
/// the open menu's grid.
|
||||
pub(super) fn remote_ghost_recipe(
|
||||
&mut self,
|
||||
p: horizon_protocol::packets::recipes::PlaceGhostRecipe,
|
||||
) {
|
||||
let open = self.remote_menu_id();
|
||||
let Some(book_type) = self.active_book() else {
|
||||
return;
|
||||
};
|
||||
if p.container != open {
|
||||
return;
|
||||
}
|
||||
let entry = self.recipe_ui.last_placed.as_ref().and_then(|id| {
|
||||
let book = self.recipe_ui.book.as_ref()?;
|
||||
book.entry(id).cloned()
|
||||
});
|
||||
if let Some(entry) = entry {
|
||||
self.recipe_ui.ghost.clear();
|
||||
self.fill_ghost(book_type, &entry);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ghost recipe ----
|
||||
|
||||
/// `fillGhostRecipe`: the recipe's ingredients faded into the slots.
|
||||
|
||||
@@ -107,7 +107,10 @@ pub(super) struct Remote {
|
||||
health: (f32, i32, f32),
|
||||
game_mode: i32,
|
||||
pub chat: Vec<(String, Instant)>,
|
||||
pub chat_input: Option<String>,
|
||||
/// Lines added since the start, for the control socket's events.
|
||||
pub chat_received: u64,
|
||||
/// The server's command tree (ClientboundCommandsPacket).
|
||||
pub commands: Option<horizon_client::command::Dispatcher>,
|
||||
action_bar: Option<(String, Instant)>,
|
||||
players: HashMap<u128, String>,
|
||||
/// `ClientPacketListener.playerInfoMap` as player-list entries, and
|
||||
@@ -292,7 +295,8 @@ impl Game<'_> {
|
||||
health: (20.0, 20, 5.0),
|
||||
game_mode: 0,
|
||||
chat: Vec::new(),
|
||||
chat_input: None,
|
||||
chat_received: 0,
|
||||
commands: None,
|
||||
action_bar: None,
|
||||
players: HashMap::new(),
|
||||
tab_players: HashMap::new(),
|
||||
@@ -464,12 +468,49 @@ impl Game<'_> {
|
||||
log::info!("[chat] {text}");
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat.push((text, Instant::now()));
|
||||
r.chat_received += 1;
|
||||
if r.chat.len() > 100 {
|
||||
r.chat.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The minecarts the server sent, for the control socket's `status`
|
||||
/// (their velocity is not tracked here).
|
||||
pub(super) fn remote_carts(&self) -> Option<Vec<serde_json::Value>> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let mut carts: Vec<&Tracked> = r
|
||||
.entities
|
||||
.values()
|
||||
.filter(|t| t.kind.ends_with("minecart"))
|
||||
.collect();
|
||||
carts.sort_by(|a, b| {
|
||||
a.position
|
||||
.partial_cmp(&b.position)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
Some(
|
||||
carts
|
||||
.into_iter()
|
||||
.map(|t| serde_json::json!({"kind": t.kind, "position": t.position, "velocity": [0.0, 0.0, 0.0]}))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The names in the server's player list, sorted.
|
||||
pub(super) fn remote_online_names(&self) -> Option<Vec<String>> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let mut names: Vec<String> = r.players.values().cloned().collect();
|
||||
names.sort();
|
||||
Some(names)
|
||||
}
|
||||
/// The open menu's window id (0: the inventory).
|
||||
pub(super) fn remote_menu_id(&self) -> i32 {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.menu.as_ref())
|
||||
.map_or(0, |m| m.0)
|
||||
}
|
||||
/// The server's last `n` chat lines, newest first.
|
||||
pub(super) fn remote_chat(&self, n: usize) -> Option<Vec<String>> {
|
||||
let r = self.remote.as_ref()?;
|
||||
@@ -1271,6 +1312,20 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Entity.DATA_SHARED_FLAGS_ID's crouching bit: another
|
||||
// player's crouch (their model and Entity.isDiscrete's name tag).
|
||||
if self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&p.id))
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
{
|
||||
for (index, _, value) in &p.values {
|
||||
if let (0, Value::Byte(flags)) = (index, value) {
|
||||
m.extra.crouching = flags & 2 != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (index, _, value) in &p.values {
|
||||
if *index == ITEM_ENTITY_ITEM
|
||||
&& let Value::Item(stack) = value
|
||||
@@ -1439,6 +1494,21 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::GameEvent(p) => match p.event {
|
||||
// GUARDIAN_ELDER_EFFECT: the apparition, and the curse sound
|
||||
// unless the elder guardian is silent.
|
||||
10 => {
|
||||
self.creatures.elder_apparition = super::mob_blocks::APPARITION_TICKS;
|
||||
if p.param == 1.0
|
||||
&& let Some(at) = self.player.as_ref().map(|m| m.position)
|
||||
{
|
||||
self.audio.play(
|
||||
"minecraft:entity.elder_guardian.curse",
|
||||
Some([at.x, at.y, at.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
self.world.weather.raining = true;
|
||||
self.world.weather.rain_level = (0.0, 0.0);
|
||||
@@ -1517,6 +1587,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::OpenScreen(p) => self.remote_open_screen(p),
|
||||
C::RecipeBookAdd(p) => self.remote_recipe_book_add(p),
|
||||
C::RecipeBookRemove(p) => self.remote_recipe_book_remove(p),
|
||||
C::RecipeBookSettings(p) => self.remote_recipe_book_settings(p),
|
||||
C::PlaceGhostRecipe(p) => self.remote_ghost_recipe(p),
|
||||
C::MerchantOffers(p) => self.remote_merchant_offers(p),
|
||||
C::ContainerClose(_) => {
|
||||
if self.screen == Screen::Inventory {
|
||||
@@ -1525,6 +1599,15 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
C::Commands(p) => {
|
||||
let tree = horizon_client::command::network::from_packet(
|
||||
&p,
|
||||
horizon_client::command::vanilla::dispatcher(),
|
||||
);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.commands = Some(tree);
|
||||
}
|
||||
}
|
||||
C::SystemChat(p) => {
|
||||
let text = plain(
|
||||
&p.content,
|
||||
@@ -2147,8 +2230,7 @@ impl Game<'_> {
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let chatting = self.remote.as_ref().is_some_and(|r| r.chat_input.is_some());
|
||||
let playing = self.screen == Screen::Playing && !chatting;
|
||||
let playing = self.screen == Screen::Playing;
|
||||
let held = |k| playing && self.keys.contains(&k);
|
||||
let keys = Keys {
|
||||
forward: held(KeyCode::KeyW),
|
||||
@@ -2418,8 +2500,7 @@ impl Game<'_> {
|
||||
if r.destroy_delay > 0 {
|
||||
r.destroy_delay -= 1;
|
||||
}
|
||||
let chatting = r.chat_input.is_some();
|
||||
if !self.left || self.screen != Screen::Playing || chatting {
|
||||
if !self.left || self.screen != Screen::Playing {
|
||||
self.stop_remote_mining();
|
||||
self.mining = None;
|
||||
return;
|
||||
@@ -2436,6 +2517,7 @@ impl Game<'_> {
|
||||
if self.remote.as_ref().is_some_and(|r| r.destroy_delay > 0) {
|
||||
return;
|
||||
}
|
||||
self.remote_ensure_carried();
|
||||
self.swing_arm();
|
||||
self.send(S::Punch(Empty));
|
||||
if self.creative {
|
||||
@@ -2587,7 +2669,22 @@ impl Game<'_> {
|
||||
self.mining = None;
|
||||
}
|
||||
/// Minecraft.startUseItem: use on the targeted block, then the item itself.
|
||||
/// MultiPlayerGameMode.ensureHasSentCarriedItem: the server hears of a
|
||||
/// hotbar change before an action that uses the held item.
|
||||
fn remote_ensure_carried(&mut self) {
|
||||
let selected = self.selected;
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if r.carried != Some(selected) {
|
||||
r.carried = Some(selected);
|
||||
self.send(S::SetCarriedItem(play::SetCarriedItem {
|
||||
slot: selected as i16,
|
||||
}));
|
||||
}
|
||||
}
|
||||
fn remote_use(&mut self) {
|
||||
self.remote_ensure_carried();
|
||||
let held_block = self
|
||||
.held()
|
||||
.and_then(|h| self.res.items.get(&h.item))
|
||||
@@ -2648,13 +2745,14 @@ impl Game<'_> {
|
||||
}
|
||||
/// Left click: attack the targeted entity (MultiPlayerGameMode.attack).
|
||||
pub(super) fn remote_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
|
||||
if self.remote.as_ref().is_some_and(|r| r.chat_input.is_some()) {
|
||||
if self.screen == Screen::Chat {
|
||||
return Ok(());
|
||||
}
|
||||
match button {
|
||||
MouseButton::Left => {
|
||||
if pressed && let Some((i, _)) = self.mob_hit() {
|
||||
let id = self.mobs[i].id as i32;
|
||||
self.remote_ensure_carried();
|
||||
self.send(S::Attack(play::Attack { id }));
|
||||
self.send(S::Punch(Empty));
|
||||
self.swing_arm();
|
||||
@@ -2707,14 +2805,8 @@ impl Game<'_> {
|
||||
}
|
||||
match key {
|
||||
KeyCode::KeyT | KeyCode::Slash | KeyCode::Enter => {
|
||||
let text = if key == KeyCode::Slash { "/" } else { "" };
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat_input = Some(text.to_owned());
|
||||
}
|
||||
self.keys.clear();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
self.capture(false);
|
||||
// ChatScreen, over the server's command tree.
|
||||
self.open_chat(if key == KeyCode::Slash { "/" } else { "" });
|
||||
Ok(true)
|
||||
}
|
||||
KeyCode::KeyQ => {
|
||||
@@ -2751,84 +2843,6 @@ impl Game<'_> {
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
pub(super) fn remote_chat_open(&self) -> bool {
|
||||
self.remote.as_ref().is_some_and(|r| r.chat_input.is_some())
|
||||
}
|
||||
/// ChatScreen: typing, Enter to send, Escape to cancel.
|
||||
pub(super) fn remote_chat_key(&mut self, event: &winit::event::KeyEvent) {
|
||||
use winit::keyboard::Key;
|
||||
if event.state != winit::event::ElementState::Pressed {
|
||||
return;
|
||||
}
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(input) = r.chat_input.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// ChatScreen's input: an EditBox of 256.
|
||||
const INPUT: horizon_client::clipboard::FieldId = ("remote.chat", 0);
|
||||
if let Some(shortcut) = shortcut {
|
||||
Self::append_shortcut(
|
||||
&mut self.text_selection,
|
||||
&mut self.clipboard,
|
||||
INPUT,
|
||||
input,
|
||||
shortcut,
|
||||
256,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Logical keys: the text, not the scancode, decides (layouts, virtual keyboards).
|
||||
let named = match &event.logical_key {
|
||||
Key::Named(n) => Some(*n),
|
||||
_ => None,
|
||||
};
|
||||
self.remote_chat_input(named, event.text.as_deref());
|
||||
}
|
||||
/// A named key or typed text in the chat input (the keyboard, or a
|
||||
/// controller's on-screen keyboard).
|
||||
pub(super) fn remote_chat_input(
|
||||
&mut self,
|
||||
named: Option<winit::keyboard::NamedKey>,
|
||||
text: Option<&str>,
|
||||
) {
|
||||
use winit::keyboard::NamedKey;
|
||||
const INPUT: horizon_client::clipboard::FieldId = ("remote.chat", 0);
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(input) = r.chat_input.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match named {
|
||||
Some(NamedKey::Escape) => {
|
||||
r.chat_input = None;
|
||||
self.capture(true);
|
||||
}
|
||||
Some(NamedKey::Backspace) => {
|
||||
if !self.text_selection.take(INPUT, input) {
|
||||
input.pop();
|
||||
}
|
||||
}
|
||||
Some(NamedKey::Enter) => {
|
||||
let message = r.chat_input.take().unwrap_or_default();
|
||||
self.send_chat(&message);
|
||||
self.capture(true);
|
||||
}
|
||||
_ => {
|
||||
if let Some(text) = text {
|
||||
self.text_selection.take(INPUT, input);
|
||||
for c in text.chars().filter(|c| !c.is_control()) {
|
||||
if input.chars().count() < 256 {
|
||||
input.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// ClientPacketListener.sendChat / sendCommand (unsigned).
|
||||
pub(super) fn send_chat(&mut self, message: &str) {
|
||||
let message = message.trim();
|
||||
@@ -3171,11 +3185,11 @@ impl Game<'_> {
|
||||
let Some(r) = &self.remote else { return };
|
||||
let atlas = &self.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let open = r.chat_input.is_some();
|
||||
let open = self.screen == Screen::Chat;
|
||||
let now = Instant::now();
|
||||
let mut y = h - 48.0;
|
||||
// Container screens cover the chat (vanilla draws them over the HUD).
|
||||
let shown = if matches!(self.screen, Screen::Playing | Screen::Dead) {
|
||||
let shown = if matches!(self.screen, Screen::Playing | Screen::Dead | Screen::Chat) {
|
||||
if open { 20 } else { 10 }
|
||||
} else {
|
||||
0
|
||||
@@ -3203,16 +3217,6 @@ impl Game<'_> {
|
||||
batch.text(atlas, text, 4.0, y, rgba(255, 255, 255, a), true);
|
||||
y -= 9.0;
|
||||
}
|
||||
if let Some(input) = &r.chat_input {
|
||||
batch.fill(atlas, 2.0, h - 14.0, w - 4.0, 12.0, rgba(0, 0, 0, 128));
|
||||
let blink = (self.started.elapsed().as_millis() / 300).is_multiple_of(2);
|
||||
let shown = if blink {
|
||||
format!("{input}_")
|
||||
} else {
|
||||
input.clone()
|
||||
};
|
||||
batch.text(atlas, &shown, 4.0, h - 12.0, rgba(224, 224, 224, 255), true);
|
||||
}
|
||||
if let Some((text, at)) = &r.action_bar
|
||||
&& now.duration_since(*at) < Duration::from_secs(3)
|
||||
{
|
||||
|
||||
@@ -600,9 +600,7 @@ impl Game<'_> {
|
||||
/// objective.
|
||||
pub(super) fn draw_tab_list(&mut self, batch: &mut HudBatch) {
|
||||
use horizon_client::tab_list::{self, TabInputs};
|
||||
let chatting = self.remote.is_some() && self.remote_chat_open();
|
||||
let key = self.screen == super::Screen::Playing
|
||||
&& !chatting
|
||||
&& self.keys.contains(&winit::keyboard::KeyCode::Tab);
|
||||
let (players, online_mode, upside_down) = match self.remote_tab_players() {
|
||||
Some(remote) => remote,
|
||||
|
||||
@@ -80,6 +80,15 @@ pub(super) struct SculkState {
|
||||
ticks: u32,
|
||||
pub tracker_loaded: bool,
|
||||
}
|
||||
|
||||
impl SculkState {
|
||||
/// Exchange the level-held parts (its sculk block entities and the
|
||||
/// chunks scanned for them) with another level's (game/levels.rs).
|
||||
pub(super) fn swap_level(&mut self, other: &mut Self) {
|
||||
std::mem::swap(&mut self.index, &mut other.index);
|
||||
std::mem::swap(&mut self.known, &mut other.known);
|
||||
}
|
||||
}
|
||||
/// An insertion-ordered set of block positions (`Level.blockEntityTickers`
|
||||
/// keeps the order block entities were added in).
|
||||
#[derive(Default)]
|
||||
@@ -1171,6 +1180,9 @@ impl Game<'_> {
|
||||
|
||||
/// `Warden.applyDarknessAround` / `MobEffectUtil.addEffectToPlayersAround`.
|
||||
pub(super) fn apply_darkness(&mut self, at: [f64; 3], radius: f64) {
|
||||
self.for_each_player(|g| g.darkness_on_player(at, radius));
|
||||
}
|
||||
fn darkness_on_player(&mut self, at: [f64; 3], radius: f64) {
|
||||
if self.creative || self.spectator() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,59 +1,81 @@
|
||||
//! A server's player between dimensions.
|
||||
//! A server's players between dimensions.
|
||||
//!
|
||||
//! The server holds one level at a time, as the in-frame game does. While a
|
||||
//! single player is online (single player on the server thread, or a world
|
||||
//! with one player in it) that level follows the player through Nether and
|
||||
//! End portals, and back to the Overworld when the player respawns. The swap
|
||||
//! is the in-frame path's (`switch_level`, the portal plans, the arrival
|
||||
//! search); the player hears it as `ServerPlayer.teleport` into another
|
||||
//! level: a Respawn packet keeping all data, then a teleport to the exit once
|
||||
//! the destination has loaded. With several players online the portals do
|
||||
//! not take anyone yet: that needs the server to hold every level.
|
||||
//! A server holds a level per dimension (game/levels.rs). A player who steps
|
||||
//! into a Nether or End portal moves to the destination's level, which opens
|
||||
//! if nobody was there: the swap is the in-frame path's portal plans and
|
||||
//! arrival search, and the player hears it as `ServerPlayer.teleport` into
|
||||
//! another level: a Respawn packet keeping all data, then a teleport to the
|
||||
//! exit once the destination has loaded around them.
|
||||
|
||||
use super::nether_portal::PLAYER_DELAY;
|
||||
use super::nether_portal::{PLAYER_DELAY, Plan};
|
||||
use super::{Dim, Game, lan::LanHost};
|
||||
use horizon_client::lan::hub::TickCtx;
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
/// A trip under way: the level has changed and its chunks are loading
|
||||
/// around `target`.
|
||||
/// A trip under way: the player is in the destination's level, whose
|
||||
/// chunks are loading around `target`.
|
||||
pub(super) struct Trip {
|
||||
conn: u32,
|
||||
dim: Dim,
|
||||
target: Vec3,
|
||||
/// Through an End portal (or out of the End by respawning).
|
||||
/// Through an End portal.
|
||||
end: bool,
|
||||
/// The Nether portal plan, whose exit is found or built on arrival.
|
||||
plan: Option<Plan>,
|
||||
}
|
||||
|
||||
/// The lone player's box: Player's standing dimensions.
|
||||
/// A player's box: Player's standing dimensions.
|
||||
const WIDTH: f64 = 0.6;
|
||||
const HEIGHT: f64 = 1.8;
|
||||
|
||||
impl Trip {
|
||||
pub(super) fn is(&self, conn: u32) -> bool {
|
||||
self.conn == conn
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether `conn` is between two levels: the server leaves its body
|
||||
/// alone until it arrives.
|
||||
pub(super) fn on_server_trip(&self, conn: u32) -> bool {
|
||||
self.server_trip.as_ref().is_some_and(|t| t.conn == conn)
|
||||
self.server_trips.iter().any(|t| t.conn == conn)
|
||||
}
|
||||
|
||||
/// Per server tick, after the guests' own: `Entity.handlePortal` for the
|
||||
/// lone player (Nether portals count up their transition time, End
|
||||
/// portals take it at once), or the arrival of the trip under way.
|
||||
/// Per server tick in each level, after the guests' own: the arrivals of
|
||||
/// the trips into this level, then `Entity.handlePortal` for each player
|
||||
/// in it (Nether portals count up their transition time, End portals
|
||||
/// take them at once).
|
||||
pub(super) fn tick_server_travel(&mut self, host: &mut LanHost) {
|
||||
if self.options.dedicated.is_none() {
|
||||
return;
|
||||
}
|
||||
if self.server_trip.is_some() {
|
||||
self.arrive_server_trip(host);
|
||||
return;
|
||||
self.arrive_server_trips(host);
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
if self.on_server_trip(conn) {
|
||||
continue;
|
||||
}
|
||||
// `Entity.portalProcess` and `portalCooldown` are the player's own.
|
||||
let mut portal = host.play.entry(conn).or_default().portal;
|
||||
std::mem::swap(&mut self.portals.player, &mut portal);
|
||||
self.handle_server_portal(host, conn);
|
||||
std::mem::swap(&mut self.portals.player, &mut portal);
|
||||
host.play.entry(conn).or_default().portal = portal;
|
||||
}
|
||||
let [g] = host.hub.guests.as_slice() else {
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal` for one player, whose portal state is in place.
|
||||
fn handle_server_portal(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
if !g.placed || !g.loaded || g.dead || g.spectator() {
|
||||
return;
|
||||
}
|
||||
let (conn, at, invulnerable, sleeping) = (
|
||||
g.conn,
|
||||
let (at, invulnerable, sleeping) = (
|
||||
Vec3::new(g.position[0], g.position[1], g.position[2]),
|
||||
g.creative(),
|
||||
g.sleeping,
|
||||
@@ -70,7 +92,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
Dim::End
|
||||
};
|
||||
self.start_server_trip(host, conn, target, true);
|
||||
self.start_server_trip(host, conn, target, None);
|
||||
}
|
||||
// Entity.setAsInsidePortal while cooling down restarts the cooldown.
|
||||
self.portals.player.cooldown = 10;
|
||||
@@ -93,11 +115,10 @@ impl Game<'_> {
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
self.portals.travel = Some(plan);
|
||||
self.start_server_trip(host, conn, plan.dim, false);
|
||||
self.start_server_trip(host, conn, plan.dim, Some(plan));
|
||||
}
|
||||
|
||||
/// A Nether portal block the lone player's box is in.
|
||||
/// A Nether portal block a player's box is in.
|
||||
fn nether_portal_inside(&self, at: Vec3) -> Option<BlockPos> {
|
||||
let w = WIDTH / 2.0 - 1.0e-5;
|
||||
let lo = [at.x - w, at.y + 1.0e-5, at.z - w];
|
||||
@@ -114,9 +135,16 @@ impl Game<'_> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The level swap, and the player told it is in the new level at the
|
||||
/// approximate exit while it loads.
|
||||
fn start_server_trip(&mut self, host: &mut LanHost, conn: u32, target: Dim, end: bool) {
|
||||
/// The player moves to `target`'s level (opened if nobody was there)
|
||||
/// and is told it is there, at the approximate exit, while it loads. The
|
||||
/// level whose tick is running is put back in place afterwards.
|
||||
fn start_server_trip(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
target: Dim,
|
||||
plan: Option<Plan>,
|
||||
) {
|
||||
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
@@ -124,105 +152,100 @@ impl Game<'_> {
|
||||
let from = Vec3::new(g.position[0], g.position[1], g.position[2]);
|
||||
let yaw = g.yaw;
|
||||
let source = self.dim;
|
||||
let end = plan.is_none();
|
||||
// Coordinates scale by 8 between the Overworld and the Nether; an
|
||||
// End portal has fixed destinations.
|
||||
let scale = match (source, target) {
|
||||
(Dim::Overworld, Dim::Nether) | (Dim::End, Dim::Nether) => 1.0 / 8.0,
|
||||
(Dim::Nether, Dim::Overworld) => 8.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
self.end_portal_travel = end;
|
||||
let switched = self.switch_level(target, from);
|
||||
let scaled = self.end_destination(target).unwrap_or(Vec3::new(
|
||||
from.x * scale,
|
||||
from.y,
|
||||
from.z * scale,
|
||||
));
|
||||
self.end_portal_travel = false;
|
||||
let destination = match switched {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::error!("could not open {}: {e}", target.id());
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.enter_level(Some(host), target) {
|
||||
log::error!("could not open {}: {e}", target.id());
|
||||
return;
|
||||
}
|
||||
let destination = match plan {
|
||||
Some(plan) => self.arrival_centre(&plan),
|
||||
None => scaled,
|
||||
};
|
||||
let destination = match self.portals.travel {
|
||||
Some(plan) if !end => self.arrival_centre(&plan),
|
||||
_ => destination,
|
||||
};
|
||||
host.hub.set_dimension(target.id());
|
||||
let at = [destination.x, destination.y, destination.z];
|
||||
let ctx = self.server_ctx();
|
||||
host.hub.change_level(i, at, yaw, &ctx);
|
||||
self.as_guest(host, conn, |g| g.trigger_dimension(source, target));
|
||||
self.server_trip = Some(Trip {
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: target,
|
||||
target: destination,
|
||||
end,
|
||||
plan,
|
||||
});
|
||||
if let Err(e) = self.enter_level(Some(host), source) {
|
||||
log::error!("could not reopen {}: {e}", source.id());
|
||||
}
|
||||
}
|
||||
|
||||
/// Once the destination has loaded around the trip's target: the exit
|
||||
/// (the Nether portal found or built, the End platform, the Overworld's
|
||||
/// spawn column) and the teleport there.
|
||||
fn arrive_server_trip(&mut self, host: &mut LanHost) {
|
||||
let Some(trip) = self.server_trip.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(i) = host.hub.guests.iter().position(|g| g.conn == trip.conn) else {
|
||||
// They left on the way.
|
||||
self.server_trip = None;
|
||||
self.portals.travel = None;
|
||||
return;
|
||||
};
|
||||
let (cx, cz) = (
|
||||
(trip.target.x.floor() as i32) >> 4,
|
||||
(trip.target.z.floor() as i32) >> 4,
|
||||
);
|
||||
if !(-2..=2).all(|dz| (-2..=2).all(|dx| self.streaming.is_lit(cx + dx, cz + dz))) {
|
||||
return;
|
||||
}
|
||||
let Some(trip) = self.server_trip.take() else {
|
||||
return;
|
||||
};
|
||||
self.resolve_arrivals();
|
||||
let (position, yaw) = if trip.end {
|
||||
match self.end_arrival(trip.target) {
|
||||
Ok((at, yaw)) => (at, yaw.unwrap_or(host.hub.guests[i].yaw)),
|
||||
Err(e) => {
|
||||
log::error!("End arrival: {e}");
|
||||
(trip.target, host.hub.guests[i].yaw)
|
||||
}
|
||||
/// The trips into the level in place whose destination has loaded
|
||||
/// around their target: the exit (the Nether portal found or built, the
|
||||
/// End platform, the Overworld's spawn column) and the teleport there.
|
||||
fn arrive_server_trips(&mut self, host: &mut LanHost) {
|
||||
let mut k = 0;
|
||||
while k < self.server_trips.len() {
|
||||
let trip = &self.server_trips[k];
|
||||
let Some(i) = host.hub.guests.iter().position(|g| g.conn == trip.conn) else {
|
||||
// They left on the way.
|
||||
self.server_trips.remove(k);
|
||||
continue;
|
||||
};
|
||||
let (cx, cz) = (
|
||||
(trip.target.x.floor() as i32) >> 4,
|
||||
(trip.target.z.floor() as i32) >> 4,
|
||||
);
|
||||
if trip.dim != self.dim
|
||||
|| !(-2..=2).all(|dz| (-2..=2).all(|dx| self.streaming.is_lit(cx + dx, cz + dz)))
|
||||
{
|
||||
k += 1;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
let plan = self.portals.travel.take();
|
||||
let exit = plan.as_ref().and_then(|plan| self.nether_exit_portal(plan));
|
||||
match (plan, exit) {
|
||||
(Some(plan), Some(exit)) => {
|
||||
let (at, rotation) = self.arrival(&plan, exit);
|
||||
(at, host.hub.guests[i].yaw + rotation)
|
||||
let trip = self.server_trips.remove(k);
|
||||
self.resolve_arrivals();
|
||||
let yaw = host.hub.guests[i].yaw;
|
||||
let (position, yaw) = if trip.end {
|
||||
match self.end_arrival(trip.target) {
|
||||
Ok((at, turned)) => (at, turned.unwrap_or(yaw)),
|
||||
Err(e) => {
|
||||
log::error!("End arrival: {e}");
|
||||
(trip.target, yaw)
|
||||
}
|
||||
}
|
||||
_ => (trip.target, host.hub.guests[i].yaw),
|
||||
}
|
||||
};
|
||||
// Player.getDimensionChangingDelay.
|
||||
self.portals.player.cooldown = 10;
|
||||
let g = &mut host.hub.guests[i];
|
||||
g.yaw = yaw;
|
||||
// A new body at the exit: no fall distance or motion carried over.
|
||||
g.motion = None;
|
||||
host.hub
|
||||
.teleport(i, Some([position.x, position.y, position.z]));
|
||||
}
|
||||
|
||||
/// `PlayerList.respawn` away from the Overworld: the lone player's level
|
||||
/// goes back to the Overworld first, where the world spawn is.
|
||||
pub(super) fn respawn_level(&mut self, host: &mut LanHost) {
|
||||
if self.options.dedicated.is_none() || self.dim == Dim::Overworld {
|
||||
return;
|
||||
} else {
|
||||
let exit = trip
|
||||
.plan
|
||||
.as_ref()
|
||||
.and_then(|plan| self.nether_exit_portal(plan));
|
||||
match (trip.plan, exit) {
|
||||
(Some(plan), Some(exit)) => {
|
||||
let (at, rotation) = self.arrival(&plan, exit);
|
||||
(at, yaw + rotation)
|
||||
}
|
||||
_ => (trip.target, yaw),
|
||||
}
|
||||
};
|
||||
// Player.getDimensionChangingDelay.
|
||||
host.play.entry(trip.conn).or_default().portal.cooldown = 10;
|
||||
let g = &mut host.hub.guests[i];
|
||||
g.yaw = yaw;
|
||||
// A new body at the exit: no fall distance or motion carried over.
|
||||
g.motion = None;
|
||||
host.hub
|
||||
.teleport(i, Some([position.x, position.y, position.z]));
|
||||
}
|
||||
if host.hub.guests.len() != 1 {
|
||||
return;
|
||||
}
|
||||
self.server_trip = None;
|
||||
self.portals.travel = None;
|
||||
self.end_portal_travel = true;
|
||||
let from = self.spawn.unwrap_or(Vec3::ZERO);
|
||||
let switched = self.switch_level(Dim::Overworld, from);
|
||||
self.end_portal_travel = false;
|
||||
if let Err(e) = switched {
|
||||
log::error!("could not open the Overworld: {e}");
|
||||
return;
|
||||
}
|
||||
host.hub.set_dimension(Dim::Overworld.id());
|
||||
}
|
||||
|
||||
/// The hub's view of the server for packets sent outside its own tick.
|
||||
|
||||
@@ -282,6 +282,20 @@ impl ServerAdvancements {
|
||||
book_add(entries, true)
|
||||
}
|
||||
|
||||
/// The recipe's display for ClientboundPlaceGhostRecipePacket (the
|
||||
/// recipes the book lists).
|
||||
pub fn ghost_display(
|
||||
&self,
|
||||
id: &str,
|
||||
sync: &SyncData,
|
||||
) -> Option<horizon_protocol::packets::recipes::RecipeDisplay> {
|
||||
let bytes = self.book_entries(id, sync, 0).into_iter().next()?;
|
||||
let mut r = horizon_protocol::buf::Reader::new(&bytes);
|
||||
horizon_protocol::packets::recipes::RecipeBookEntry::read(&mut r)
|
||||
.ok()
|
||||
.map(|e| e.display)
|
||||
}
|
||||
|
||||
/// ClientboundRecipeBookAddPacket.Entry for each display of a recipe:
|
||||
/// highlighted, and with a notification unless the recipe turns it off.
|
||||
fn book_entries(&self, id: &str, sync: &SyncData, flags: i8) -> Vec<Vec<u8>> {
|
||||
|
||||
@@ -25,7 +25,7 @@ impl Hub {
|
||||
for g in self
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed && Some(g.conn) != except)
|
||||
.filter(|g| self.here(g) && Some(g.conn) != except)
|
||||
{
|
||||
let d: f64 = (0..3).map(|k| (g.position[k] - at[k]).powi(2)).sum();
|
||||
if d <= range * range {
|
||||
@@ -464,7 +464,7 @@ impl Hub {
|
||||
let Some(menu) = self.guests[i]
|
||||
.menu
|
||||
.as_ref()
|
||||
.filter(|_| self.guests[i].placed)
|
||||
.filter(|_| self.here(&self.guests[i]))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use super::tracking;
|
||||
use horizon_protocol::{
|
||||
buf::pack_degrees,
|
||||
entity_data::Value,
|
||||
entity_data::{Value, serializer},
|
||||
item::Stack,
|
||||
packets::play::{
|
||||
self, AddEntity, EntityPositionSync, MoveEntity, MoveEntityPos, MoveEntityPosRot,
|
||||
@@ -229,12 +229,25 @@ impl Tracker {
|
||||
}
|
||||
// ServerEntity.sendDirtyEntityData.
|
||||
if dirty {
|
||||
let changed: Vec<_> = e
|
||||
let mut changed: Vec<_> = e
|
||||
.meta
|
||||
.iter()
|
||||
.filter(|m| !s.meta.contains(m))
|
||||
.cloned()
|
||||
.collect();
|
||||
// A value back at its default is left out of the state, but
|
||||
// the client still holds the old one: Entity's shared flags
|
||||
// and pose go back to 0 (a player standing up again).
|
||||
for (index, serializer, _) in &s.meta {
|
||||
let default = match (*index, *serializer) {
|
||||
(0, serializer::BYTE) => Value::Byte(0),
|
||||
(6, serializer::POSE) => Value::Id(0),
|
||||
_ => continue,
|
||||
};
|
||||
if !e.meta.iter().any(|m| m.0 == *index) {
|
||||
changed.push((*index, *serializer, default));
|
||||
}
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
out.push(play::Clientbound::SetEntityData(SetEntityData {
|
||||
id: e.id,
|
||||
@@ -364,4 +377,35 @@ mod tests {
|
||||
};
|
||||
assert_eq!(eq.slots, vec![(0, None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleared_flags_and_pose_go_back_to_their_default() {
|
||||
let mut t = Tracker::default();
|
||||
let mut e = state(0.0);
|
||||
t.spawn(&e, 100);
|
||||
t.update(&e);
|
||||
// A crouching player: the crouch flag and the crouching pose.
|
||||
e.meta = vec![
|
||||
(0, serializer::BYTE, Value::Byte(2)),
|
||||
(6, serializer::POSE, Value::Id(5)),
|
||||
];
|
||||
t.update(&e);
|
||||
e.meta.clear();
|
||||
let data = t
|
||||
.update(&e)
|
||||
.into_iter()
|
||||
.find_map(|p| match p {
|
||||
play::Clientbound::SetEntityData(d) => Some(d),
|
||||
_ => None,
|
||||
})
|
||||
.expect("standing up is sent");
|
||||
assert_eq!(
|
||||
data.values,
|
||||
vec![
|
||||
(0, serializer::BYTE, Value::Byte(0)),
|
||||
(6, serializer::POSE, Value::Id(0)),
|
||||
]
|
||||
);
|
||||
assert!(t.update(&e).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,9 @@ pub struct Guest {
|
||||
pub dead: bool,
|
||||
/// The join packets were sent (PlayerList.placeNewPlayer ran).
|
||||
pub placed: bool,
|
||||
/// The level the player is in (`ServerPlayer.level().dimension()`): the
|
||||
/// hub's level passes only reach the players in theirs.
|
||||
pub dimension: String,
|
||||
/// The open block container, if any.
|
||||
pub menu: Option<super::menu::Menu>,
|
||||
pub next_window: i32,
|
||||
@@ -226,6 +229,7 @@ impl Guest {
|
||||
moves_from_ground: false,
|
||||
dead: false,
|
||||
placed: false,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
menu: None,
|
||||
next_window: 0,
|
||||
drag: None,
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod game_event {
|
||||
pub const CHANGE_GAME_MODE: u8 = 3;
|
||||
pub const RAIN_LEVEL: u8 = 7;
|
||||
pub const THUNDER_LEVEL: u8 = 8;
|
||||
pub const GUARDIAN_ELDER_EFFECT: u8 = 10;
|
||||
pub const LEVEL_CHUNKS_LOAD_START: u8 = 13;
|
||||
}
|
||||
|
||||
@@ -112,6 +113,9 @@ pub struct Hub {
|
||||
last_rate: f32,
|
||||
ticking: (f32, bool),
|
||||
weather: (bool, f32, f32),
|
||||
/// The clock and weather last sent in the levels not in place, by
|
||||
/// dimension (`set_dimension` swaps them with the fields above).
|
||||
level_sync: std::collections::HashMap<String, LevelSync>,
|
||||
/// Operators and their permission levels (dedicated servers).
|
||||
pub ops: std::collections::HashMap<u128, u8>,
|
||||
chat_type: i32,
|
||||
@@ -180,6 +184,24 @@ fn tracked(cx: i32, cz: i32, r: i32, x: i32, z: i32) -> bool {
|
||||
dx * dx + dz * dz < i64::from(r) * i64::from(r)
|
||||
}
|
||||
|
||||
/// What a level's players were last told of its clock and weather.
|
||||
#[derive(Clone, Copy)]
|
||||
struct LevelSync {
|
||||
last_clock: i64,
|
||||
last_rate: f32,
|
||||
weather: (bool, f32, f32),
|
||||
}
|
||||
|
||||
impl Default for LevelSync {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_clock: i64::MIN,
|
||||
last_rate: 0.0,
|
||||
weather: (false, 0.0, 0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Hub {
|
||||
pub fn new(lan: Lan, sync: Arc<SyncData>, encoder: ChunkEncoder, config: HubConfig) -> Self {
|
||||
let chat_type = sync
|
||||
@@ -204,6 +226,7 @@ impl Hub {
|
||||
ticking: (20.0, false),
|
||||
ops: Default::default(),
|
||||
weather: (false, 0.0, 0.0),
|
||||
level_sync: Default::default(),
|
||||
chat_type,
|
||||
dimension_type,
|
||||
clock_ids,
|
||||
@@ -257,6 +280,18 @@ impl Hub {
|
||||
g.sender.send(body.clone());
|
||||
}
|
||||
}
|
||||
/// Whether a guest is placed in the level in place (`config.dimension`).
|
||||
pub fn here(&self, g: &Guest) -> bool {
|
||||
g.placed && g.dimension == self.config.dimension
|
||||
}
|
||||
/// `PlayerList.broadcastAll(packet, dimension)`: the players in the
|
||||
/// level in place.
|
||||
pub fn broadcast_level(&self, packet: &C) {
|
||||
let body = packet.encode();
|
||||
for g in self.guests.iter().filter(|g| self.here(g)) {
|
||||
g.sender.send(body.clone());
|
||||
}
|
||||
}
|
||||
pub fn broadcast_except(&self, conn: u32, packet: &C) {
|
||||
let body = packet.encode();
|
||||
for g in self.guests.iter().filter(|g| g.placed && g.conn != conn) {
|
||||
@@ -448,6 +483,7 @@ impl Hub {
|
||||
guest.joined_tick = self.ticks;
|
||||
guest.position = ctx.spawn;
|
||||
guest.previous_position = ctx.spawn;
|
||||
guest.dimension = self.config.dimension.clone();
|
||||
self.guests.push(guest);
|
||||
conn
|
||||
}
|
||||
@@ -732,6 +768,10 @@ impl Hub {
|
||||
g.position = p;
|
||||
g.previous_position = p;
|
||||
}
|
||||
// The moves the guest sent before it (handled at the next tick) are
|
||||
// from where it was: vanilla handles them on arrival, before the
|
||||
// teleport, so a fall in them does not hurt it where it lands.
|
||||
g.moves.clear();
|
||||
g.teleport_counter = (g.teleport_counter + 1) & 0x7FFF_FFFF;
|
||||
let id = g.teleport_counter;
|
||||
g.awaiting_teleport = Some((id, g.position));
|
||||
@@ -838,6 +878,20 @@ impl Hub {
|
||||
}
|
||||
|
||||
/// ServerPlayer.initInventoryMenu / sendAllDataToRemote for the inventory menu.
|
||||
/// ClientboundPlaceGhostRecipePacket: the recipe `id` faded into the
|
||||
/// guest's menu `container`, whose items were missing.
|
||||
pub fn send_ghost_recipe(&mut self, conn: u32, container: i32, id: &str) {
|
||||
let Some(display) = self.sync.advancements.ghost_display(id, &self.sync) else {
|
||||
return;
|
||||
};
|
||||
self.send_to(
|
||||
conn,
|
||||
&C::PlaceGhostRecipe(horizon_protocol::packets::recipes::PlaceGhostRecipe {
|
||||
container,
|
||||
display,
|
||||
}),
|
||||
);
|
||||
}
|
||||
pub fn send_inventory(&mut self, i: usize) {
|
||||
let items: Vec<Option<ItemStack>> = (0..46).map(|s| self.guests[i].menu_slot(s)).collect();
|
||||
let net: Vec<_> = items.iter().map(|s| self.stack(s)).collect();
|
||||
@@ -857,7 +911,19 @@ impl Hub {
|
||||
|
||||
// ---- per tick ------------------------------------------------------
|
||||
|
||||
/// A whole tick with one level: the network, the level, then the
|
||||
/// players' latencies.
|
||||
pub fn tick(&mut self, ctx: &mut TickCtx) -> Vec<HostEvent> {
|
||||
let mut events = self.poll(ctx);
|
||||
events.extend(self.tick_level(ctx));
|
||||
self.end_tick();
|
||||
events
|
||||
}
|
||||
|
||||
/// The network since the last tick, once per server tick before any
|
||||
/// level ticks: joins (into the level in place), leaves, and each guest's
|
||||
/// packets queued for its own level's pass.
|
||||
pub fn poll(&mut self, ctx: &mut TickCtx) -> Vec<HostEvent> {
|
||||
self.ticks += 1;
|
||||
let mut events = Vec::new();
|
||||
for event in self.lan.poll() {
|
||||
@@ -893,13 +959,25 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// The level in place's part of the tick (`ServerLevel.tick`'s chunk
|
||||
/// map and the connections of the players in it): their packets, then
|
||||
/// the chunks, block changes, containers and entities they see.
|
||||
pub fn tick_level(&mut self, ctx: &mut TickCtx) -> Vec<HostEvent> {
|
||||
let mut events = Vec::new();
|
||||
for i in 0..self.guests.len() {
|
||||
if self.guests[i].placed {
|
||||
if self.here(&self.guests[i]) {
|
||||
self.process_inbox(i, ctx, &mut events);
|
||||
}
|
||||
}
|
||||
if !self.guests.iter().any(|g| g.placed) {
|
||||
if self.guests.is_empty() {
|
||||
if !self.guests.iter().any(|g| self.here(g)) {
|
||||
if !self
|
||||
.guests
|
||||
.iter()
|
||||
.any(|g| g.dimension == self.config.dimension)
|
||||
{
|
||||
let _ = ctx.world.block_log.take();
|
||||
}
|
||||
return events;
|
||||
@@ -909,7 +987,7 @@ impl Hub {
|
||||
}
|
||||
self.sync_world(ctx);
|
||||
for i in 0..self.guests.len() {
|
||||
if self.guests[i].placed {
|
||||
if self.here(&self.guests[i]) {
|
||||
self.update_chunks(i, ctx);
|
||||
}
|
||||
}
|
||||
@@ -917,7 +995,7 @@ impl Hub {
|
||||
// ServerGamePacketListenerImpl.tick: the actions handled since the last tick are
|
||||
// acknowledged after the block changes they made were sent, so a client's predicted
|
||||
// blocks settle on the server's states.
|
||||
for g in &self.guests {
|
||||
for g in self.guests.iter().filter(|g| self.here(g)) {
|
||||
let sequence = g.ack_up_to.replace(-1);
|
||||
if sequence >= 0 {
|
||||
g.send(&C::BlockChangedAck(play::BlockChangedAck { sequence }));
|
||||
@@ -926,11 +1004,16 @@ impl Hub {
|
||||
self.sync_containers(ctx.world);
|
||||
self.track_entities(ctx);
|
||||
for i in 0..self.guests.len() {
|
||||
if self.guests[i].placed {
|
||||
if self.here(&self.guests[i]) {
|
||||
self.sync_player_state(i);
|
||||
self.sync_effects(i);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// After every level: `PlayerList.tick`.
|
||||
pub fn end_tick(&mut self) {
|
||||
let latencies: Vec<_> = self
|
||||
.guests
|
||||
.iter()
|
||||
@@ -952,7 +1035,6 @@ impl Hub {
|
||||
entries,
|
||||
}));
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
fn remove_guest(&mut self, i: usize, reason: &str) -> Guest {
|
||||
@@ -983,7 +1065,7 @@ impl Hub {
|
||||
// MinecraftServer.tickServer: the game time every 20 ticks
|
||||
// (forceGameTimeSynchronization, no clocks).
|
||||
if self.ticks.is_multiple_of(20) {
|
||||
self.broadcast(&C::SetTime(play::SetTime {
|
||||
self.broadcast_level(&C::SetTime(play::SetTime {
|
||||
game_time: world.game_time,
|
||||
clocks: Vec::new(),
|
||||
}));
|
||||
@@ -999,7 +1081,7 @@ impl Hub {
|
||||
.sync
|
||||
.id("minecraft:world_clock", "minecraft:overworld")
|
||||
.unwrap_or(0);
|
||||
self.broadcast(&C::SetTime(play::SetTime {
|
||||
self.broadcast_level(&C::SetTime(play::SetTime {
|
||||
game_time: world.game_time,
|
||||
clocks: vec![play::ClockState {
|
||||
clock: overworld,
|
||||
@@ -1018,7 +1100,7 @@ impl Hub {
|
||||
let w = &world.weather;
|
||||
let (raining, rain, thunder) = self.weather;
|
||||
if w.raining != raining {
|
||||
self.broadcast(&C::GameEvent(play::GameEvent {
|
||||
self.broadcast_level(&C::GameEvent(play::GameEvent {
|
||||
event: if w.raining {
|
||||
game_event::START_RAINING
|
||||
} else {
|
||||
@@ -1028,13 +1110,13 @@ impl Hub {
|
||||
}));
|
||||
}
|
||||
if (w.rain_level.1 - rain).abs() > f32::EPSILON {
|
||||
self.broadcast(&C::GameEvent(play::GameEvent {
|
||||
self.broadcast_level(&C::GameEvent(play::GameEvent {
|
||||
event: game_event::RAIN_LEVEL,
|
||||
param: w.rain_level.1,
|
||||
}));
|
||||
}
|
||||
if (w.thunder_level.1 - thunder).abs() > f32::EPSILON {
|
||||
self.broadcast(&C::GameEvent(play::GameEvent {
|
||||
self.broadcast_level(&C::GameEvent(play::GameEvent {
|
||||
event: game_event::THUNDER_LEVEL,
|
||||
param: w.thunder_level.1,
|
||||
}));
|
||||
@@ -1164,7 +1246,7 @@ impl Hub {
|
||||
for ((sx, sy, sz), positions) in sections {
|
||||
let viewers: Vec<usize> = (0..self.guests.len())
|
||||
.filter(|&i| {
|
||||
self.guests[i].placed && self.guests[i].sent_chunks.contains(&(sx, sz))
|
||||
self.here(&self.guests[i]) && self.guests[i].sent_chunks.contains(&(sx, sz))
|
||||
})
|
||||
.collect();
|
||||
if viewers.is_empty() {
|
||||
@@ -1329,7 +1411,7 @@ impl Hub {
|
||||
));
|
||||
}
|
||||
let placed: Vec<usize> = (0..self.guests.len())
|
||||
.filter(|&i| self.guests[i].placed)
|
||||
.filter(|&i| self.here(&self.guests[i]))
|
||||
.collect();
|
||||
let guest_states: Vec<_> = placed
|
||||
.into_iter()
|
||||
@@ -1363,7 +1445,7 @@ impl Hub {
|
||||
let by_id: std::collections::HashMap<i32, usize> =
|
||||
all.iter().enumerate().map(|(i, e)| (e.id, i)).collect();
|
||||
for gi in 0..self.guests.len() {
|
||||
if !self.guests[gi].placed {
|
||||
if !self.here(&self.guests[gi]) {
|
||||
continue;
|
||||
}
|
||||
let view = self.guests[gi].view_distance;
|
||||
@@ -1772,12 +1854,15 @@ impl Hub {
|
||||
let mode = self.guests[i].game_mode;
|
||||
let mut info = self.spawn_info(mode);
|
||||
// Player.getLastDeathLocation, set where the player died.
|
||||
// The level they died in: theirs until now.
|
||||
info.last_death = self.guests[i].last_death.map(|pos| play::GlobalPos {
|
||||
dimension: self.config.dimension.clone(),
|
||||
dimension: self.guests[i].dimension.clone(),
|
||||
pos,
|
||||
});
|
||||
self.close_container_for(i);
|
||||
let g = &mut self.guests[i];
|
||||
// PlayerList.respawn: into the level in place (the respawn level).
|
||||
g.dimension = self.config.dimension.clone();
|
||||
// ServerLevel.removePlayerImmediately: every entity paired with the dead player is
|
||||
// removed (ServerEntity.removePairing, one packet each). Its chunks are dropped
|
||||
// without ForgetLevelChunk (PlayerChunkSender.dropChunk skips dead players): the
|
||||
@@ -1788,6 +1873,17 @@ impl Hub {
|
||||
g.tracker.clear();
|
||||
g.sent_chunks.clear();
|
||||
g.chunk_queue.clear();
|
||||
// The other players lose the dead player's entity too, and are sent
|
||||
// the respawned one as a new entity (ServerLevel.addRespawnedPlayer):
|
||||
// their clients drop a dead body and would not show it alive again.
|
||||
let id = g.entity_id;
|
||||
for (j, other) in self.guests.iter_mut().enumerate() {
|
||||
if j != i && other.tracker.is_tracking(id) {
|
||||
other.send(&C::RemoveEntities(play::RemoveEntities { ids: vec![id] }));
|
||||
other.tracker.forget(id);
|
||||
}
|
||||
}
|
||||
let g = &mut self.guests[i];
|
||||
// ServerPlayer.restoreFrom: the game mode again (onUpdateAbilities).
|
||||
g.send(&C::PlayerAbilities(play::PlayerAbilities {
|
||||
flags: g.ability_flags(),
|
||||
@@ -1846,6 +1942,20 @@ impl Hub {
|
||||
/// The level the players are in: the server holds one at a time, and the
|
||||
/// hub's packets name it.
|
||||
pub fn set_dimension(&mut self, dimension: &str) {
|
||||
if self.config.dimension == dimension {
|
||||
return;
|
||||
}
|
||||
// What this level's players were told stays with it.
|
||||
let leaving = LevelSync {
|
||||
last_clock: self.last_clock,
|
||||
last_rate: self.last_rate,
|
||||
weather: self.weather,
|
||||
};
|
||||
let entering = self.level_sync.remove(dimension).unwrap_or_default();
|
||||
self.level_sync
|
||||
.insert(std::mem::take(&mut self.config.dimension), leaving);
|
||||
(self.last_clock, self.last_rate, self.weather) =
|
||||
(entering.last_clock, entering.last_rate, entering.weather);
|
||||
self.config.dimension = dimension.to_owned();
|
||||
self.dimension_type = self
|
||||
.sync
|
||||
@@ -1865,6 +1975,7 @@ impl Hub {
|
||||
let info = self.spawn_info(mode);
|
||||
self.close_container_for(i);
|
||||
let g = &mut self.guests[i];
|
||||
g.dimension = self.config.dimension.clone();
|
||||
g.tracker.clear();
|
||||
g.sent_chunks.clear();
|
||||
g.chunk_queue.clear();
|
||||
|
||||
@@ -71,6 +71,10 @@ pub fn apply(guest: &mut Guest, level: &Level) {
|
||||
guest.selected = (level.selected_slot.max(0) as usize).min(8);
|
||||
guest.saved = level.player_extra.clone();
|
||||
guest.recipe_book = level.recipe_book.clone();
|
||||
guest.dimension = match level.dimension.as_str() {
|
||||
d @ ("minecraft:the_nether" | "minecraft:the_end") => d.to_owned(),
|
||||
_ => "minecraft:overworld".to_owned(),
|
||||
};
|
||||
if level.ender_items.len() == 27 {
|
||||
guest.ender_items = level.ender_items.clone();
|
||||
}
|
||||
@@ -100,7 +104,6 @@ pub fn apply(guest: &mut Guest, level: &Level) {
|
||||
pub fn save(guest: &Guest, world: &Path) {
|
||||
let mut level = Level {
|
||||
player_uuid: Some(uuid_ints(guest.uuid)),
|
||||
dimension: "minecraft:overworld".into(),
|
||||
..Level::default()
|
||||
};
|
||||
store(guest, &mut level);
|
||||
@@ -114,6 +117,7 @@ pub fn save(guest: &Guest, world: &Path) {
|
||||
/// Put a guest's state in `level`'s player fields.
|
||||
pub fn store(guest: &Guest, level: &mut Level) {
|
||||
level.position = Some(guest.position);
|
||||
level.dimension = guest.dimension.clone();
|
||||
level.rotation = [guest.yaw, guest.pitch];
|
||||
level.health = guest.health;
|
||||
level.food = guest.food;
|
||||
|
||||
@@ -756,6 +756,15 @@ impl RecipeIndex {
|
||||
r.first_display..r.first_display + r.displays as i32
|
||||
})
|
||||
}
|
||||
/// The recipe a display id belongs to (`RecipeManager.getRecipeFromDisplay`).
|
||||
pub fn recipe_of_display(&self, display: i32) -> Option<&str> {
|
||||
self.recipes
|
||||
.iter()
|
||||
.find(|(_, r)| {
|
||||
(r.first_display..r.first_display + r.displays as i32).contains(&display)
|
||||
})
|
||||
.map(|(id, _)| id.as_str())
|
||||
}
|
||||
pub fn first_display(&self, id: &str) -> Option<i32> {
|
||||
self.recipes
|
||||
.get(id)
|
||||
|
||||
@@ -23,6 +23,10 @@ impl<'a> Reader<'a> {
|
||||
pub fn position(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
/// The bytes read since `start` (a `position()`).
|
||||
pub fn since(&self, start: usize) -> &'a [u8] {
|
||||
&self.data[start.min(self.pos)..self.pos]
|
||||
}
|
||||
pub fn rest(&mut self) -> &'a [u8] {
|
||||
let rest = &self.data[self.pos..];
|
||||
self.pos = self.data.len();
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod configuration;
|
||||
pub mod handshake;
|
||||
pub mod login;
|
||||
pub mod play;
|
||||
pub mod recipes;
|
||||
pub mod status;
|
||||
|
||||
use crate::buf::{Codec, Reader, Writer};
|
||||
|
||||
@@ -901,6 +901,177 @@ pub struct CommandNode {
|
||||
/// Custom suggestion provider (`minecraft:ask_server`).
|
||||
pub suggestions: Option<String>,
|
||||
}
|
||||
/// The `minecraft:command_argument_type` registry of 26.3 in id order
|
||||
/// (ArgumentTypeInfos.bootstrap).
|
||||
pub const ARGUMENT_TYPES: &[&str] = &[
|
||||
"brigadier:bool",
|
||||
"brigadier:float",
|
||||
"brigadier:double",
|
||||
"brigadier:integer",
|
||||
"brigadier:long",
|
||||
"brigadier:string",
|
||||
"minecraft:entity",
|
||||
"minecraft:game_profile",
|
||||
"minecraft:block_pos",
|
||||
"minecraft:column_pos",
|
||||
"minecraft:vec3",
|
||||
"minecraft:vec2",
|
||||
"minecraft:block_state",
|
||||
"minecraft:block_predicate",
|
||||
"minecraft:item_stack",
|
||||
"minecraft:item_predicate",
|
||||
"minecraft:team_color",
|
||||
"minecraft:hex_color",
|
||||
"minecraft:component",
|
||||
"minecraft:style",
|
||||
"minecraft:message",
|
||||
"minecraft:nbt_compound_tag",
|
||||
"minecraft:nbt_tag",
|
||||
"minecraft:nbt_path",
|
||||
"minecraft:objective",
|
||||
"minecraft:objective_criteria",
|
||||
"minecraft:operation",
|
||||
"minecraft:particle",
|
||||
"minecraft:angle",
|
||||
"minecraft:rotation",
|
||||
"minecraft:scoreboard_slot",
|
||||
"minecraft:score_holder",
|
||||
"minecraft:swizzle",
|
||||
"minecraft:team",
|
||||
"minecraft:item_slot",
|
||||
"minecraft:item_slots",
|
||||
"minecraft:resource_location",
|
||||
"minecraft:function",
|
||||
"minecraft:entity_anchor",
|
||||
"minecraft:int_range",
|
||||
"minecraft:float_range",
|
||||
"minecraft:dimension",
|
||||
"minecraft:gamemode",
|
||||
"minecraft:time",
|
||||
"minecraft:resource_or_tag",
|
||||
"minecraft:resource_or_tag_key",
|
||||
"minecraft:resource",
|
||||
"minecraft:resource_key",
|
||||
"minecraft:resource_selector",
|
||||
"minecraft:template_mirror",
|
||||
"minecraft:template_rotation",
|
||||
"minecraft:heightmap",
|
||||
"minecraft:loot_table",
|
||||
"minecraft:loot_predicate",
|
||||
"minecraft:loot_modifier",
|
||||
"minecraft:context_float_provider",
|
||||
"minecraft:context_int_provider",
|
||||
"minecraft:slot_source",
|
||||
"minecraft:dialog",
|
||||
"minecraft:feature",
|
||||
"minecraft:swing_animation",
|
||||
"minecraft:uuid",
|
||||
];
|
||||
|
||||
/// The properties of an argument parser (`ArgumentTypeInfo.Template`) as
|
||||
/// one of the 26.3 parsers decoded from the network.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ArgumentProperties {
|
||||
None,
|
||||
Integer {
|
||||
min: Option<i32>,
|
||||
max: Option<i32>,
|
||||
},
|
||||
Long {
|
||||
min: Option<i64>,
|
||||
max: Option<i64>,
|
||||
},
|
||||
Float {
|
||||
min: Option<f32>,
|
||||
max: Option<f32>,
|
||||
},
|
||||
Double {
|
||||
min: Option<f64>,
|
||||
max: Option<f64>,
|
||||
},
|
||||
/// StringArgumentType.StringType: 0 word, 1 phrase, 2 greedy.
|
||||
String(i32),
|
||||
Entity {
|
||||
single: bool,
|
||||
players_only: bool,
|
||||
},
|
||||
ScoreHolder {
|
||||
multiple: bool,
|
||||
},
|
||||
Time {
|
||||
min: i32,
|
||||
},
|
||||
/// The resource arguments' registry.
|
||||
Registry(String),
|
||||
}
|
||||
|
||||
impl ArgumentProperties {
|
||||
/// ArgumentTypeInfo.deserializeFromNetwork for parser `id`.
|
||||
pub fn read(r: &mut Reader, id: i32) -> Result<Self> {
|
||||
let name = usize::try_from(id)
|
||||
.ok()
|
||||
.and_then(|i| ARGUMENT_TYPES.get(i))
|
||||
.ok_or_else(|| crate::Error::new(format!("unknown argument type {id}")))?;
|
||||
let flagged = |r: &mut Reader| -> Result<(bool, bool)> {
|
||||
let f = r.u8()?;
|
||||
Ok((f & 1 != 0, f & 2 != 0))
|
||||
};
|
||||
Ok(match *name {
|
||||
"brigadier:integer" => {
|
||||
let (min, max) = flagged(r)?;
|
||||
let min = if min { Some(r.i32()?) } else { None };
|
||||
let max = if max { Some(r.i32()?) } else { None };
|
||||
Self::Integer { min, max }
|
||||
}
|
||||
"brigadier:long" => {
|
||||
let (min, max) = flagged(r)?;
|
||||
let min = if min { Some(r.i64()?) } else { None };
|
||||
let max = if max { Some(r.i64()?) } else { None };
|
||||
Self::Long { min, max }
|
||||
}
|
||||
"brigadier:float" => {
|
||||
let (min, max) = flagged(r)?;
|
||||
let min = if min { Some(r.f32()?) } else { None };
|
||||
let max = if max { Some(r.f32()?) } else { None };
|
||||
Self::Float { min, max }
|
||||
}
|
||||
"brigadier:double" => {
|
||||
let (min, max) = flagged(r)?;
|
||||
let min = if min { Some(r.f64()?) } else { None };
|
||||
let max = if max { Some(r.f64()?) } else { None };
|
||||
Self::Double { min, max }
|
||||
}
|
||||
"brigadier:string" => Self::String(r.varint()?),
|
||||
"minecraft:entity" => {
|
||||
let f = r.u8()?;
|
||||
Self::Entity {
|
||||
single: f & 1 != 0,
|
||||
players_only: f & 2 != 0,
|
||||
}
|
||||
}
|
||||
"minecraft:score_holder" => Self::ScoreHolder {
|
||||
multiple: r.u8()? & 1 != 0,
|
||||
},
|
||||
"minecraft:time" => Self::Time { min: r.i32()? },
|
||||
"minecraft:resource"
|
||||
| "minecraft:resource_key"
|
||||
| "minecraft:resource_or_tag"
|
||||
| "minecraft:resource_or_tag_key"
|
||||
| "minecraft:resource_selector" => Self::Registry(r.string(32767)?),
|
||||
_ => Self::None,
|
||||
})
|
||||
}
|
||||
/// Decodes a node's raw `parser` bytes.
|
||||
pub fn decode(id: i32, bytes: &[u8]) -> Result<Self> {
|
||||
Self::read(&mut Reader::new(bytes), id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads past an argument node's parser properties.
|
||||
fn read_argument_properties(r: &mut Reader, id: i32) -> Result<()> {
|
||||
ArgumentProperties::read(r, id).map(|_| ())
|
||||
}
|
||||
|
||||
/// The command tree: nodes and the index of the root.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Commands {
|
||||
@@ -926,11 +1097,14 @@ impl Codec<Commands> for Commands {
|
||||
name = r.string(32767)?;
|
||||
}
|
||||
if kind == 2 {
|
||||
// Properties are parser specific; only the property-less parsers
|
||||
// can be skipped without a table, so keep the rest of the packet.
|
||||
return Err(crate::Error::new("argument nodes are not decoded"));
|
||||
let id = r.varint()?;
|
||||
let start = r.position();
|
||||
read_argument_properties(r, id)?;
|
||||
parser = Some((id, r.since(start).to_vec()));
|
||||
if flags & 16 != 0 {
|
||||
suggestions = Some(r.string(32767)?);
|
||||
}
|
||||
}
|
||||
let _ = (&mut parser, &mut suggestions);
|
||||
nodes.push(CommandNode {
|
||||
kind,
|
||||
executable: flags & 4 != 0,
|
||||
@@ -1189,6 +1363,10 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
|
||||
ChunkBatchStart(Empty) = CHUNK_BATCH_START,
|
||||
ClearTitles(Bool1) = CLEAR_TITLES,
|
||||
Commands(Commands) = COMMANDS,
|
||||
RecipeBookAdd(super::recipes::RecipeBookAdd) = RECIPE_BOOK_ADD,
|
||||
RecipeBookRemove(super::recipes::RecipeBookRemove) = RECIPE_BOOK_REMOVE,
|
||||
RecipeBookSettings(super::recipes::RecipeBookSettings) = RECIPE_BOOK_SETTINGS,
|
||||
PlaceGhostRecipe(super::recipes::PlaceGhostRecipe) = PLACE_GHOST_RECIPE,
|
||||
ContainerClose(ContainerClose) = CONTAINER_CLOSE,
|
||||
ContainerSetContent(ContainerSetContent) = CONTAINER_SET_CONTENT,
|
||||
ContainerSetData(ContainerSetData) = CONTAINER_SET_DATA,
|
||||
@@ -1510,6 +1688,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound {
|
||||
ChangeGameMode(ChangeGameMode) = CHANGE_GAME_MODE,
|
||||
Chat(Chat) = CHAT,
|
||||
ChatCommand(ChatCommand) = CHAT_COMMAND,
|
||||
PlaceRecipe(super::recipes::PlaceRecipe) = PLACE_RECIPE,
|
||||
ChunkBatchReceived(ChunkBatchReceived) = CHUNK_BATCH_RECEIVED,
|
||||
ClientCommand(ClientCommand) = CLIENT_COMMAND,
|
||||
ClientInformation(ClientInformation) = CLIENT_INFORMATION,
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
//! The recipe book's packets and the recipe displays they carry
|
||||
//! (`RecipeDisplay`, `SlotDisplay`, `RecipeDisplayEntry`): the 26.3
|
||||
//! `minecraft:recipe_display` and `minecraft:slot_display` types in id order.
|
||||
use crate::{
|
||||
Result,
|
||||
buf::{Codec, Reader, Writer},
|
||||
item::{Stack, Template},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `HolderSet<Item>` on the network: a tag by name, or item ids.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum HolderSet {
|
||||
Tag(String),
|
||||
Items(Vec<i32>),
|
||||
}
|
||||
impl HolderSet {
|
||||
pub fn read(r: &mut Reader) -> Result<Self> {
|
||||
let n = r.varint()? - 1;
|
||||
if n == -1 {
|
||||
return Ok(Self::Tag(r.string(32767)?));
|
||||
}
|
||||
let n = usize::try_from(n).map_err(|_| crate::Error::new("negative holder set size"))?;
|
||||
if n > r.remaining() {
|
||||
return Err(crate::Error::new("holder set larger than the packet"));
|
||||
}
|
||||
Ok(Self::Items(
|
||||
(0..n).map(|_| r.varint()).collect::<Result<_>>()?,
|
||||
))
|
||||
}
|
||||
pub fn write(&self, w: &mut Writer) {
|
||||
match self {
|
||||
Self::Tag(name) => {
|
||||
w.varint(0);
|
||||
w.string(name);
|
||||
}
|
||||
Self::Items(ids) => {
|
||||
w.varint(ids.len() as i32 + 1);
|
||||
ids.iter().for_each(|id| w.varint(*id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A trim pattern as `ByteBufCodecs.holder` sends it: a registry id, or
|
||||
/// the pattern itself.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum TrimPattern {
|
||||
Id(i32),
|
||||
Direct {
|
||||
asset: String,
|
||||
description: Tag,
|
||||
decal: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// `SlotDisplay`: what a recipe book slot shows.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SlotDisplay {
|
||||
Empty,
|
||||
AnyFuel,
|
||||
WithAnyPotion(Box<SlotDisplay>),
|
||||
/// The source, kept to the stacks with this data component type.
|
||||
OnlyWithComponent(Box<SlotDisplay>, i32),
|
||||
Item(i32),
|
||||
ItemStack(Stack),
|
||||
Tag(HolderSet),
|
||||
Dyed {
|
||||
dye: Box<SlotDisplay>,
|
||||
target: Box<SlotDisplay>,
|
||||
},
|
||||
SmithingTrim {
|
||||
base: Box<SlotDisplay>,
|
||||
material: Box<SlotDisplay>,
|
||||
pattern: TrimPattern,
|
||||
},
|
||||
WithRemainder {
|
||||
input: Box<SlotDisplay>,
|
||||
remainder: Box<SlotDisplay>,
|
||||
},
|
||||
Composite(Vec<SlotDisplay>),
|
||||
}
|
||||
|
||||
/// How deep slot displays may nest (composites of composites…).
|
||||
const MAX_DEPTH: u32 = 32;
|
||||
|
||||
impl SlotDisplay {
|
||||
pub fn read(r: &mut Reader) -> Result<Self> {
|
||||
Self::read_at(r, 0)
|
||||
}
|
||||
fn read_at(r: &mut Reader, depth: u32) -> Result<Self> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(crate::Error::new("slot displays nest too deep"));
|
||||
}
|
||||
let inner = |r: &mut Reader| Self::read_at(r, depth + 1).map(Box::new);
|
||||
Ok(match r.varint()? {
|
||||
0 => Self::Empty,
|
||||
1 => Self::AnyFuel,
|
||||
2 => Self::WithAnyPotion(inner(r)?),
|
||||
3 => {
|
||||
let source = inner(r)?;
|
||||
Self::OnlyWithComponent(source, r.varint()?)
|
||||
}
|
||||
4 => Self::Item(r.varint()?),
|
||||
5 => Self::ItemStack(Template::read(r)?),
|
||||
6 => Self::Tag(HolderSet::read(r)?),
|
||||
7 => Self::Dyed {
|
||||
dye: inner(r)?,
|
||||
target: inner(r)?,
|
||||
},
|
||||
8 => {
|
||||
let base = inner(r)?;
|
||||
let material = inner(r)?;
|
||||
let pattern = match r.varint()? {
|
||||
0 => TrimPattern::Direct {
|
||||
asset: r.string(32767)?,
|
||||
description: r.nbt()?,
|
||||
decal: r.bool()?,
|
||||
},
|
||||
id => TrimPattern::Id(id - 1),
|
||||
};
|
||||
Self::SmithingTrim {
|
||||
base,
|
||||
material,
|
||||
pattern,
|
||||
}
|
||||
}
|
||||
9 => Self::WithRemainder {
|
||||
input: inner(r)?,
|
||||
remainder: inner(r)?,
|
||||
},
|
||||
10 => {
|
||||
let n = r.count(r.remaining())?;
|
||||
let mut out = Vec::with_capacity(n.min(256));
|
||||
for _ in 0..n {
|
||||
out.push(Self::read_at(r, depth + 1)?);
|
||||
}
|
||||
Self::Composite(out)
|
||||
}
|
||||
id => return Err(crate::Error::new(format!("unknown slot display {id}"))),
|
||||
})
|
||||
}
|
||||
pub fn write(&self, w: &mut Writer) {
|
||||
match self {
|
||||
Self::Empty => w.varint(0),
|
||||
Self::AnyFuel => w.varint(1),
|
||||
Self::WithAnyPotion(d) => {
|
||||
w.varint(2);
|
||||
d.write(w);
|
||||
}
|
||||
Self::OnlyWithComponent(d, component) => {
|
||||
w.varint(3);
|
||||
d.write(w);
|
||||
w.varint(*component);
|
||||
}
|
||||
Self::Item(id) => {
|
||||
w.varint(4);
|
||||
w.varint(*id);
|
||||
}
|
||||
Self::ItemStack(stack) => {
|
||||
w.varint(5);
|
||||
Template::write(stack, w);
|
||||
}
|
||||
Self::Tag(set) => {
|
||||
w.varint(6);
|
||||
set.write(w);
|
||||
}
|
||||
Self::Dyed { dye, target } => {
|
||||
w.varint(7);
|
||||
dye.write(w);
|
||||
target.write(w);
|
||||
}
|
||||
Self::SmithingTrim {
|
||||
base,
|
||||
material,
|
||||
pattern,
|
||||
} => {
|
||||
w.varint(8);
|
||||
base.write(w);
|
||||
material.write(w);
|
||||
match pattern {
|
||||
TrimPattern::Id(id) => w.varint(id + 1),
|
||||
TrimPattern::Direct {
|
||||
asset,
|
||||
description,
|
||||
decal,
|
||||
} => {
|
||||
w.varint(0);
|
||||
w.string(asset);
|
||||
w.nbt(description);
|
||||
w.bool(*decal);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::WithRemainder { input, remainder } => {
|
||||
w.varint(9);
|
||||
input.write(w);
|
||||
remainder.write(w);
|
||||
}
|
||||
Self::Composite(list) => {
|
||||
w.varint(10);
|
||||
w.varint(list.len() as i32);
|
||||
list.iter().for_each(|d| d.write(w));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_list(r: &mut Reader) -> Result<Vec<SlotDisplay>> {
|
||||
let n = r.count(r.remaining())?;
|
||||
let mut out = Vec::with_capacity(n.min(256));
|
||||
for _ in 0..n {
|
||||
out.push(SlotDisplay::read(r)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
fn write_list(list: &[SlotDisplay], w: &mut Writer) {
|
||||
w.varint(list.len() as i32);
|
||||
list.iter().for_each(|d| d.write(w));
|
||||
}
|
||||
|
||||
/// `RecipeDisplay`: a recipe as the recipe book draws it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum RecipeDisplay {
|
||||
Shapeless {
|
||||
ingredients: Vec<SlotDisplay>,
|
||||
result: SlotDisplay,
|
||||
station: SlotDisplay,
|
||||
},
|
||||
Shaped {
|
||||
width: i32,
|
||||
height: i32,
|
||||
ingredients: Vec<SlotDisplay>,
|
||||
result: SlotDisplay,
|
||||
station: SlotDisplay,
|
||||
},
|
||||
Furnace {
|
||||
ingredient: SlotDisplay,
|
||||
fuel: SlotDisplay,
|
||||
result: SlotDisplay,
|
||||
station: SlotDisplay,
|
||||
duration: i32,
|
||||
experience: f32,
|
||||
},
|
||||
Stonecutter {
|
||||
input: SlotDisplay,
|
||||
result: SlotDisplay,
|
||||
station: SlotDisplay,
|
||||
},
|
||||
Smithing {
|
||||
template: SlotDisplay,
|
||||
base: SlotDisplay,
|
||||
addition: SlotDisplay,
|
||||
result: SlotDisplay,
|
||||
station: SlotDisplay,
|
||||
},
|
||||
}
|
||||
impl RecipeDisplay {
|
||||
pub fn result(&self) -> &SlotDisplay {
|
||||
match self {
|
||||
Self::Shapeless { result, .. }
|
||||
| Self::Shaped { result, .. }
|
||||
| Self::Furnace { result, .. }
|
||||
| Self::Stonecutter { result, .. }
|
||||
| Self::Smithing { result, .. } => result,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Codec<RecipeDisplay> for RecipeDisplay {
|
||||
fn read(r: &mut Reader) -> Result<Self> {
|
||||
let slot = SlotDisplay::read;
|
||||
Ok(match r.varint()? {
|
||||
0 => Self::Shapeless {
|
||||
ingredients: read_list(r)?,
|
||||
result: slot(r)?,
|
||||
station: slot(r)?,
|
||||
},
|
||||
1 => {
|
||||
let (width, height) = (r.varint()?, r.varint()?);
|
||||
let ingredients = read_list(r)?;
|
||||
if i64::from(width) * i64::from(height) != ingredients.len() as i64 {
|
||||
return Err(crate::Error::new("shaped display size mismatch"));
|
||||
}
|
||||
Self::Shaped {
|
||||
width,
|
||||
height,
|
||||
ingredients,
|
||||
result: slot(r)?,
|
||||
station: slot(r)?,
|
||||
}
|
||||
}
|
||||
2 => Self::Furnace {
|
||||
ingredient: slot(r)?,
|
||||
fuel: slot(r)?,
|
||||
result: slot(r)?,
|
||||
station: slot(r)?,
|
||||
duration: r.varint()?,
|
||||
experience: r.f32()?,
|
||||
},
|
||||
3 => Self::Stonecutter {
|
||||
input: slot(r)?,
|
||||
result: slot(r)?,
|
||||
station: slot(r)?,
|
||||
},
|
||||
4 => Self::Smithing {
|
||||
template: slot(r)?,
|
||||
base: slot(r)?,
|
||||
addition: slot(r)?,
|
||||
result: slot(r)?,
|
||||
station: slot(r)?,
|
||||
},
|
||||
id => return Err(crate::Error::new(format!("unknown recipe display {id}"))),
|
||||
})
|
||||
}
|
||||
fn write(v: &Self, w: &mut Writer) {
|
||||
match v {
|
||||
Self::Shapeless {
|
||||
ingredients,
|
||||
result,
|
||||
station,
|
||||
} => {
|
||||
w.varint(0);
|
||||
write_list(ingredients, w);
|
||||
result.write(w);
|
||||
station.write(w);
|
||||
}
|
||||
Self::Shaped {
|
||||
width,
|
||||
height,
|
||||
ingredients,
|
||||
result,
|
||||
station,
|
||||
} => {
|
||||
w.varint(1);
|
||||
w.varint(*width);
|
||||
w.varint(*height);
|
||||
write_list(ingredients, w);
|
||||
result.write(w);
|
||||
station.write(w);
|
||||
}
|
||||
Self::Furnace {
|
||||
ingredient,
|
||||
fuel,
|
||||
result,
|
||||
station,
|
||||
duration,
|
||||
experience,
|
||||
} => {
|
||||
w.varint(2);
|
||||
ingredient.write(w);
|
||||
fuel.write(w);
|
||||
result.write(w);
|
||||
station.write(w);
|
||||
w.varint(*duration);
|
||||
w.f32(*experience);
|
||||
}
|
||||
Self::Stonecutter {
|
||||
input,
|
||||
result,
|
||||
station,
|
||||
} => {
|
||||
w.varint(3);
|
||||
input.write(w);
|
||||
result.write(w);
|
||||
station.write(w);
|
||||
}
|
||||
Self::Smithing {
|
||||
template,
|
||||
base,
|
||||
addition,
|
||||
result,
|
||||
station,
|
||||
} => {
|
||||
w.varint(4);
|
||||
template.write(w);
|
||||
base.write(w);
|
||||
addition.write(w);
|
||||
result.write(w);
|
||||
station.write(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `RecipeDisplayEntry` with `ClientboundRecipeBookAddPacket.Entry`'s flags
|
||||
/// (1 show a notification, 2 highlight as new).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RecipeBookEntry {
|
||||
pub id: i32,
|
||||
pub display: RecipeDisplay,
|
||||
/// The recipe group (0 none, else the group's index + 1).
|
||||
pub group: i32,
|
||||
/// `minecraft:recipe_book_category` id.
|
||||
pub category: i32,
|
||||
/// The ingredients the "craftable" filter checks, if the recipe has any.
|
||||
pub requirements: Option<Vec<HolderSet>>,
|
||||
pub flags: i8,
|
||||
}
|
||||
|
||||
/// ClientboundRecipeBookAddPacket: the entries, and whether they replace the
|
||||
/// client's book.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RecipeBookAdd {
|
||||
pub entries: Vec<RecipeBookEntry>,
|
||||
pub replace: bool,
|
||||
}
|
||||
impl RecipeBookEntry {
|
||||
pub fn read(r: &mut Reader) -> Result<Self> {
|
||||
let id = r.varint()?;
|
||||
let display = RecipeDisplay::read(r)?;
|
||||
let group = r.varint()?;
|
||||
let category = r.varint()?;
|
||||
let requirements = if r.bool()? {
|
||||
let k = r.count(r.remaining())?;
|
||||
let mut list = Vec::with_capacity(k.min(64));
|
||||
for _ in 0..k {
|
||||
list.push(HolderSet::read(r)?);
|
||||
}
|
||||
Some(list)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Self {
|
||||
id,
|
||||
display,
|
||||
group,
|
||||
category,
|
||||
requirements,
|
||||
flags: r.i8()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl Codec<RecipeBookAdd> for RecipeBookAdd {
|
||||
fn read(r: &mut Reader) -> Result<Self> {
|
||||
let n = r.count(r.remaining())?;
|
||||
let mut entries = Vec::with_capacity(n.min(4096));
|
||||
for _ in 0..n {
|
||||
entries.push(RecipeBookEntry::read(r)?);
|
||||
}
|
||||
Ok(Self {
|
||||
entries,
|
||||
replace: r.bool()?,
|
||||
})
|
||||
}
|
||||
fn write(v: &Self, w: &mut Writer) {
|
||||
w.varint(v.entries.len() as i32);
|
||||
for e in &v.entries {
|
||||
w.varint(e.id);
|
||||
RecipeDisplay::write(&e.display, w);
|
||||
w.varint(e.group);
|
||||
w.varint(e.category);
|
||||
w.bool(e.requirements.is_some());
|
||||
if let Some(list) = &e.requirements {
|
||||
w.varint(list.len() as i32);
|
||||
list.iter().for_each(|s| s.write(w));
|
||||
}
|
||||
w.i8(e.flags);
|
||||
}
|
||||
w.bool(v.replace);
|
||||
}
|
||||
}
|
||||
|
||||
/// ClientboundRecipeBookRemovePacket: the display ids.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RecipeBookRemove {
|
||||
pub ids: Vec<i32>,
|
||||
}
|
||||
impl Codec<RecipeBookRemove> for RecipeBookRemove {
|
||||
fn read(r: &mut Reader) -> Result<Self> {
|
||||
let n = r.count(r.remaining())?;
|
||||
Ok(Self {
|
||||
ids: (0..n).map(|_| r.varint()).collect::<Result<_>>()?,
|
||||
})
|
||||
}
|
||||
fn write(v: &Self, w: &mut Writer) {
|
||||
w.varint(v.ids.len() as i32);
|
||||
v.ids.iter().for_each(|id| w.varint(*id));
|
||||
}
|
||||
}
|
||||
|
||||
/// RecipeBookSettings: each book's (open, filtering), in `RecipeBookType`
|
||||
/// order (crafting, furnace, blast furnace, smoker).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct RecipeBookSettings {
|
||||
pub books: [(bool, bool); 4],
|
||||
}
|
||||
impl Codec<RecipeBookSettings> for RecipeBookSettings {
|
||||
fn read(r: &mut Reader) -> Result<Self> {
|
||||
let mut books = [(false, false); 4];
|
||||
for b in &mut books {
|
||||
*b = (r.bool()?, r.bool()?);
|
||||
}
|
||||
Ok(Self { books })
|
||||
}
|
||||
fn write(v: &Self, w: &mut Writer) {
|
||||
for (open, filtering) in v.books {
|
||||
w.bool(open);
|
||||
w.bool(filtering);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ClientboundPlaceGhostRecipePacket: the recipe to show faded in a menu
|
||||
/// whose items were missing.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PlaceGhostRecipe {
|
||||
pub container: i32,
|
||||
pub display: RecipeDisplay,
|
||||
}
|
||||
impl Codec<PlaceGhostRecipe> for PlaceGhostRecipe {
|
||||
fn read(r: &mut Reader) -> Result<Self> {
|
||||
Ok(Self {
|
||||
container: r.varint()?,
|
||||
display: RecipeDisplay::read(r)?,
|
||||
})
|
||||
}
|
||||
fn write(v: &Self, w: &mut Writer) {
|
||||
w.varint(v.container);
|
||||
RecipeDisplay::write(&v.display, w);
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerboundPlaceRecipePacket: place the recipe with this display id in
|
||||
/// the open menu, as many times as possible when `use_max` (shift).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PlaceRecipe {
|
||||
pub container: i32,
|
||||
pub recipe: i32,
|
||||
pub use_max: bool,
|
||||
}
|
||||
impl Codec<PlaceRecipe> for PlaceRecipe {
|
||||
fn read(r: &mut Reader) -> Result<Self> {
|
||||
Ok(Self {
|
||||
container: r.varint()?,
|
||||
recipe: r.varint()?,
|
||||
use_max: r.bool()?,
|
||||
})
|
||||
}
|
||||
fn write(v: &Self, w: &mut Writer) {
|
||||
w.varint(v.container);
|
||||
w.varint(v.recipe);
|
||||
w.bool(v.use_max);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn book_entries_round_trip() {
|
||||
let add = RecipeBookAdd {
|
||||
entries: vec![
|
||||
RecipeBookEntry {
|
||||
id: 7,
|
||||
display: RecipeDisplay::Shaped {
|
||||
width: 2,
|
||||
height: 1,
|
||||
ingredients: vec![
|
||||
SlotDisplay::Tag(HolderSet::Tag("minecraft:planks".into())),
|
||||
SlotDisplay::Empty,
|
||||
],
|
||||
result: SlotDisplay::ItemStack(Stack::new(12, 4)),
|
||||
station: SlotDisplay::Item(300),
|
||||
},
|
||||
group: 3,
|
||||
category: 3,
|
||||
requirements: Some(vec![HolderSet::Items(vec![1, 2])]),
|
||||
flags: 3,
|
||||
},
|
||||
RecipeBookEntry {
|
||||
id: 8,
|
||||
display: RecipeDisplay::Smithing {
|
||||
template: SlotDisplay::Composite(vec![SlotDisplay::Item(1)]),
|
||||
base: SlotDisplay::WithRemainder {
|
||||
input: Box::new(SlotDisplay::AnyFuel),
|
||||
remainder: Box::new(SlotDisplay::Item(2)),
|
||||
},
|
||||
addition: SlotDisplay::OnlyWithComponent(
|
||||
Box::new(SlotDisplay::WithAnyPotion(Box::new(SlotDisplay::Item(3)))),
|
||||
9,
|
||||
),
|
||||
result: SlotDisplay::SmithingTrim {
|
||||
base: Box::new(SlotDisplay::Item(4)),
|
||||
material: Box::new(SlotDisplay::Dyed {
|
||||
dye: Box::new(SlotDisplay::Item(5)),
|
||||
target: Box::new(SlotDisplay::Item(6)),
|
||||
}),
|
||||
pattern: TrimPattern::Id(2),
|
||||
},
|
||||
station: SlotDisplay::Item(7),
|
||||
},
|
||||
group: 0,
|
||||
category: 11,
|
||||
requirements: None,
|
||||
flags: 0,
|
||||
},
|
||||
],
|
||||
replace: true,
|
||||
};
|
||||
let mut w = Writer::new();
|
||||
RecipeBookAdd::write(&add, &mut w);
|
||||
let mut r = Reader::new(&w.data);
|
||||
assert_eq!(RecipeBookAdd::read(&mut r).unwrap(), add);
|
||||
assert_eq!(r.remaining(), 0);
|
||||
}
|
||||
}
|
||||
@@ -218,29 +218,38 @@ def scenario(d, out):
|
||||
d('click_slot', slot=log_slot) # pick up the logs
|
||||
d('click_slot', slot=100, right=True) # put one in the crafting grid
|
||||
d('click_slot', slot=log_slot) # put the rest back
|
||||
d('wait', ticks=2) # the server crafts the result (single player's server thread)
|
||||
result = next(s for s in d('screen')['slots'] if s['slot'] == 200)
|
||||
assert result.get('item') == planks_id and result['count'] == 4, result
|
||||
d('click_slot', slot=200, shift=True) # craft into the inventory
|
||||
d('close')
|
||||
d('wait', ticks=2)
|
||||
inventory = d('inventory')
|
||||
assert inventory['counts'].get(planks_id, 0) >= 4, inventory
|
||||
|
||||
# 5. Place a plank beside the player and read it back.
|
||||
p = d('state')['player']['block_pos']
|
||||
target = None
|
||||
hotbar = d('inventory')['hotbar']
|
||||
if not any(s.get('item') == planks_id for s in hotbar):
|
||||
raise Failed(f'{planks_id} did not land in the hotbar: {hotbar}')
|
||||
target = placed = None
|
||||
for dx, dz in [(2, 0), (-2, 0), (0, 2), (0, -2), (2, 1), (1, 2), (-2, -1), (-1, -2)]:
|
||||
at = (p[0] + dx, p[1], p[2] + dz)
|
||||
here = d('block', x=at[0], y=at[1], z=at[2])['block']
|
||||
below = d('block', x=at[0], y=at[1] - 1, z=at[2])['block']
|
||||
if here in ('minecraft:air', 'minecraft:short_grass') and below not in ('minecraft:air', 'minecraft:water'):
|
||||
target = at
|
||||
break
|
||||
if here not in ('minecraft:air', 'minecraft:short_grass') or below in ('minecraft:air', 'minecraft:water'):
|
||||
continue
|
||||
# The tree's trunk above the broken log can hide a spot's faces: try the next one.
|
||||
try:
|
||||
placed = d('place', x=at[0], y=at[1], z=at[2], item=planks_id)
|
||||
except Failed as e:
|
||||
if 'no visible face' in str(e):
|
||||
continue
|
||||
raise
|
||||
target = at
|
||||
break
|
||||
if target is None:
|
||||
raise Failed(f'no free spot beside {p} to place a block')
|
||||
hotbar = d('inventory')['hotbar']
|
||||
if not any(s.get('item') == planks_id for s in hotbar):
|
||||
raise Failed(f'{planks_id} did not land in the hotbar: {hotbar}')
|
||||
placed = d('place', x=target[0], y=target[1], z=target[2], item=planks_id)
|
||||
assert placed['changed'], placed
|
||||
back = d('block', x=target[0], y=target[1], z=target[2])
|
||||
assert back['block'] == planks_id, back
|
||||
@@ -296,6 +305,7 @@ def ui_scenario(d, out):
|
||||
|
||||
# 3. The inventory's slots are widgets too, with their stacks.
|
||||
d('give', item='oak_log', count=2)
|
||||
d('wait', ticks=2) # single player's server thread gives them
|
||||
d('open_inventory')
|
||||
tree = widgets(d)
|
||||
log = next(w for w in tree.values() if w['kind'] == 'slot' and (w.get('stack') or {}).get('item') == 'minecraft:oak_log')
|
||||
@@ -303,6 +313,7 @@ def ui_scenario(d, out):
|
||||
d('click', id=log['id'])
|
||||
d('click', id='slot-100', right=True)
|
||||
d('click', id=log['id'])
|
||||
d('wait', ticks=2)
|
||||
assert widgets(d)['slot-200']['stack']['item'] == 'minecraft:oak_planks'
|
||||
d('close')
|
||||
shot = d('screenshot', path=str(out / f'{d.name}-ui.png'))
|
||||
|
||||
+253
-17
@@ -10,8 +10,10 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
- armour: each side draws the other player in that player's own armour;
|
||||
- nametags: each side draws the other player's name above them;
|
||||
- chest: a chest the guest opens opens on the guest's screen, its lid opens on both sides and
|
||||
closes on both when the guest closes it;
|
||||
- crafting: the guest crafts planks in the inventory's 2x2 grid and opens a crafting table;
|
||||
closes on both when the guest closes it; the host keeps playing with its mouse look (#687);
|
||||
- crafting: the guest crafts planks in the inventory's 2x2 grid and opens a crafting table, which
|
||||
leaves the host's screen and mouse look alone;
|
||||
- sign: a sign the guest places doesn't open a sign editor on the host;
|
||||
- damage: a guest in survival takes fall damage;
|
||||
- mobs: with the host in creative, a zombie and a skeleton go for the guest and hurt them;
|
||||
- hazards: a guest in survival gets hungry sprint-jumping and is hurt by lava, a block at the eyes,
|
||||
@@ -19,7 +21,15 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
- pvp: the guest and the host hit each other with a sword; with the pvp game rule off, nothing;
|
||||
- containers: the guest's ender chest holds the guest's items, not the host's, and a double chest
|
||||
opens as 9x6 with the right half first;
|
||||
- feedback: the guest's command feedback names the guest and shows the numbers.
|
||||
- feedback: the guest's command feedback names the guest and shows the numbers;
|
||||
- commands: the guest's chat completes commands and their arguments over the server's command
|
||||
tree (Tab), and runs what it completed;
|
||||
- recipes: the guest's recipe book lists the planks recipe the logs unlock, and clicking it
|
||||
places a log in the inventory's crafting grid (the server places it);
|
||||
- effects: a beacon's power reaches every player in range, the host and the guest;
|
||||
- portals: (--server-thread) the server holds a level per dimension: the guest goes to the Nether
|
||||
through a portal while the host stays, then the host goes to the End while the guest is in
|
||||
the Nether, and both come back.
|
||||
|
||||
Each check is reported on its own (a failing one doesn't stop the others) and the screenshots of
|
||||
both sides go to --out (host-*.png, guest-*.png).
|
||||
@@ -27,6 +37,7 @@ both sides go to --out (host-*.png, guest-*.png).
|
||||
python3 tools/lan_e2e.py # build with the fast profile, run every check
|
||||
python3 tools/lan_e2e.py --bin-dir target/fast # prebuilt binaries
|
||||
python3 tools/lan_e2e.py --check chest --check tab
|
||||
python3 tools/lan_e2e.py --server-thread --check portals # the host's world on its server thread
|
||||
|
||||
Needs the prepared resources in .local/horizon-client and, on Linux, vscreen (the desktop); on
|
||||
macOS both games render offscreen. Games started here have no audio (HORIZON_NO_AUDIO=1).
|
||||
@@ -48,7 +59,9 @@ from agent_control_e2e import Cli, Failed, game_env # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GUEST_NAME = 'Guest'
|
||||
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'damage', 'mobs', 'hazards', 'pvp', 'containers', 'feedback', 'trials']
|
||||
# The host plays single player on its server thread (--server-thread).
|
||||
SERVER_THREAD = False
|
||||
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'containers', 'feedback', 'commands', 'recipes', 'trials', 'effects', 'portals']
|
||||
|
||||
|
||||
class Game:
|
||||
@@ -102,6 +115,17 @@ def remote(guest):
|
||||
return guest('status')['server']
|
||||
|
||||
|
||||
def lan_guest(host):
|
||||
"""The guest's entry in the host's `lan status` (on the server thread the owner is listed too)."""
|
||||
guests = host.raw('lan', 'status')['guests']
|
||||
return next(g for g in guests if g['name'] == GUEST_NAME)
|
||||
|
||||
|
||||
def dead(guest):
|
||||
"""Whether the guest is dead: its death screen (the last health it was sent can stay above 0)."""
|
||||
return guest('state').get('screen') == 'dead' or remote(guest)['health'] <= 0
|
||||
|
||||
|
||||
def back_to_game(game):
|
||||
"""Close whatever screen a failed check left open, without opening the pause menu."""
|
||||
try:
|
||||
@@ -161,9 +185,15 @@ def check_armour(host, guest, out):
|
||||
face_each_other(host, guest)
|
||||
host.shot(out, 'armour')
|
||||
guest.shot(out, 'armour')
|
||||
seen = host.raw('lan', 'status')['guests'][0].get('model_armor')
|
||||
assert seen == [None, 'minecraft:diamond_chestplate', 'minecraft:diamond_leggings', None], \
|
||||
f'the host draws the guest in {seen}'
|
||||
if SERVER_THREAD:
|
||||
# The host's window is a client of its server thread: what it draws is what it was sent.
|
||||
seen = [e for e in remote(host).get('player_equipment', [])]
|
||||
assert all(any(item in json.dumps(e) for e in seen) for item in
|
||||
('diamond_chestplate', 'diamond_leggings')), f'the host draws the guest in {seen}'
|
||||
else:
|
||||
seen = lan_guest(host).get('model_armor')
|
||||
assert seen == [None, 'minecraft:diamond_chestplate', 'minecraft:diamond_leggings', None], \
|
||||
f'the host draws the guest in {seen}'
|
||||
host_gear = [e for e in remote(guest).get('player_equipment', [])]
|
||||
assert any('golden_chestplate' in json.dumps(e) for e in host_gear), f'the guest sees the host in {host_gear}'
|
||||
return 'each in their own'
|
||||
@@ -180,6 +210,19 @@ def check_nametags(host, guest, out):
|
||||
return {'host': h, 'guest': g}
|
||||
|
||||
|
||||
def grab(game):
|
||||
"""Mouse look on, as a player's grabbed pointer: an agent's mouse-move stands in for it."""
|
||||
game.raw('mouse-move', 1, 0)
|
||||
game.raw('mouse-move', -1, 0)
|
||||
|
||||
|
||||
def host_unaffected(host, what):
|
||||
"""What a guest opens is the guest's: the host keeps its world on screen and its mouse look (#687)."""
|
||||
state = host('state')
|
||||
assert state.get('screen') == 'playing', f'{what} opened the {state.get("screen")} screen on the host'
|
||||
assert state.get('pointer_grabbed'), f"{what} released the host's pointer"
|
||||
|
||||
|
||||
def lid(game, pos):
|
||||
return game.raw('block', *pos).get('lid', 0.0)
|
||||
|
||||
@@ -189,9 +232,11 @@ def check_chest(host, guest, out):
|
||||
chest = (x, y, z + 2)
|
||||
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:chest')
|
||||
guest.wait(10)
|
||||
grab(host)
|
||||
guest.raw('interact', *chest)
|
||||
screen = guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
||||
guest.wait(20)
|
||||
host_unaffected(host, "the guest's chest")
|
||||
opened = {'guest': lid(guest, chest), 'host': lid(host, chest)}
|
||||
host.raw('look-at', chest[0] + 0.5, chest[1] + 0.5, chest[2] + 0.5)
|
||||
host.wait(2)
|
||||
@@ -231,8 +276,10 @@ def check_crafting(host, guest, out):
|
||||
table = (x, y, z - 2)
|
||||
guest('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:crafting_table')
|
||||
guest.wait(10)
|
||||
grab(host)
|
||||
guest.raw('interact', *table)
|
||||
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
|
||||
host_unaffected(host, "the guest's crafting table")
|
||||
size = guest('screen').get('craft_size')
|
||||
assert size == 3, f'the crafting table opened a {size}x{size} grid'
|
||||
# Two planks one above the other in the 3x3 grid: four sticks.
|
||||
@@ -252,6 +299,104 @@ def check_crafting(host, guest, out):
|
||||
return {'planks': planks, 'table': counts}
|
||||
|
||||
|
||||
def check_sign(host, guest, out):
|
||||
x, y, z = (int(v // 1) for v in pos_of(guest))
|
||||
spot = (x + 2, y, z)
|
||||
guest('command', command=f'setblock {spot[0]} {spot[1] - 1} {spot[2]} minecraft:stone')
|
||||
guest('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
|
||||
guest('command', command='give @s minecraft:oak_sign 1')
|
||||
guest.wait(10)
|
||||
grab(host)
|
||||
guest.raw('place', *spot, '--item', 'minecraft:oak_sign')
|
||||
guest.wait(20)
|
||||
placed = host.raw('block', *spot).get('block')
|
||||
host.shot(out, 'sign')
|
||||
assert placed and 'sign' in placed, f'the guest placed {placed}'
|
||||
host_unaffected(host, "the guest's sign")
|
||||
return placed
|
||||
|
||||
|
||||
def effect_ids(game):
|
||||
return [e.get('id', '').removeprefix('minecraft:') for e in game('state')['player'].get('effects', [])]
|
||||
|
||||
|
||||
def check_effects(host, guest, out):
|
||||
x, y, z = (int(v // 1) for v in pos_of(host))
|
||||
bx, by, bz = x - 3, y, z - 3
|
||||
host('command', command=f'fill {bx - 1} {by - 1} {bz - 1} {bx + 1} {by - 1} {bz + 1} minecraft:iron_block')
|
||||
host('command', command=f'setblock {bx} {by} {bz} minecraft:beacon{{primary_effect:"minecraft:speed"}}')
|
||||
# BeaconBlockEntity.tick applies its powers every 80 ticks, once the beam has reached the sky.
|
||||
seen = {}
|
||||
for _ in range(15):
|
||||
host.wait(20)
|
||||
seen = {'host': effect_ids(host), 'guest': effect_ids(guest)}
|
||||
if all('speed' in v for v in seen.values()):
|
||||
break
|
||||
host.shot(out, 'effects')
|
||||
guest.shot(out, 'effects')
|
||||
assert 'speed' in seen['host'] and 'speed' in seen['guest'], f'beacon effects: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
def wait_dimension(game, dimension, ticks):
|
||||
"""Wait up to `ticks` for the game to be in `dimension` with its world loaded around it."""
|
||||
for _ in range(ticks // 20):
|
||||
state = game('state')
|
||||
if state.get('dimension') == dimension and state.get('player'):
|
||||
return state
|
||||
game.wait(20)
|
||||
return game('state')
|
||||
|
||||
|
||||
def nether_portal(game, x, y, z):
|
||||
"""A lit portal along x, two blocks wide and three tall, its inside starting at x y z."""
|
||||
game('command', command=f'fill {x - 1} {y - 1} {z} {x + 2} {y + 3} {z} minecraft:obsidian')
|
||||
game('command', command=f'fill {x} {y} {z} {x + 1} {y + 2} {z} minecraft:nether_portal[axis=x]')
|
||||
|
||||
|
||||
def check_portals(host, guest, out):
|
||||
if not SERVER_THREAD:
|
||||
return 'skipped (needs --server-thread)'
|
||||
x, y, z = (int(v // 1) for v in pos_of(guest))
|
||||
nether_portal(guest, x, y, z + 3)
|
||||
guest.wait(5)
|
||||
guest('command', command=f'tp @s {x + 0.5} {y} {z + 3.5}')
|
||||
# players_nether_portal_default_delay: 80 ticks in survival.
|
||||
there = wait_dimension(guest, 'minecraft:the_nether', 600)
|
||||
guest.wait(40)
|
||||
guest.shot(out, 'portals-nether')
|
||||
host_dim = host('state').get('dimension')
|
||||
assert there.get('dimension') == 'minecraft:the_nether', f'the guest is in {there.get("dimension")}'
|
||||
assert host_dim == 'minecraft:overworld', f'the host went to {host_dim} with the guest'
|
||||
arrived = [int(v // 1) for v in pos_of(guest)]
|
||||
below = guest.raw('block', arrived[0], arrived[1] - 1, arrived[2])
|
||||
assert below.get('loaded') and below.get('block') != 'minecraft:air', f'under the guest in the Nether: {below}'
|
||||
# The host takes an End portal while the guest is in the Nether: three levels at once.
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
host('command', command=f'setblock {hx} {hy} {hz} minecraft:end_portal')
|
||||
end = wait_dimension(host, 'minecraft:the_end', 600)
|
||||
host.wait(40)
|
||||
host.shot(out, 'portals-end')
|
||||
guest_dim = guest('state').get('dimension')
|
||||
assert end.get('dimension') == 'minecraft:the_end', f'the host is in {end.get("dimension")}'
|
||||
assert guest_dim == 'minecraft:the_nether', f'the guest left the Nether for {guest_dim}'
|
||||
# Both come back: the host through an exit portal, the guest out of its portal and in again.
|
||||
ex, ey, ez = (int(v // 1) for v in pos_of(host))
|
||||
host('command', command=f'setblock {ex + 2} {ey} {ez} minecraft:end_portal')
|
||||
host('command', command=f'tp @s {ex + 2.5} {ey} {ez + 0.5}')
|
||||
guest('command', command=f'tp @s {arrived[0] + 0.5} {arrived[1]} {arrived[2] - 2.5}')
|
||||
guest.wait(30)
|
||||
guest('command', command=f'tp @s {arrived[0] + 0.5} {arrived[1]} {arrived[2] + 0.5}')
|
||||
home = wait_dimension(host, 'minecraft:overworld', 600)
|
||||
back = wait_dimension(guest, 'minecraft:overworld', 600)
|
||||
guest.wait(40)
|
||||
host.shot(out, 'portals-back')
|
||||
guest.shot(out, 'portals-back')
|
||||
assert home.get('dimension') == 'minecraft:overworld', f'the host stayed in {home.get("dimension")}'
|
||||
assert back.get('dimension') == 'minecraft:overworld', f'the guest stayed in {back.get("dimension")}'
|
||||
return {'guest_nether': arrived, 'host_end': [ex, ey, ez], 'back': pos_of(guest)}
|
||||
|
||||
|
||||
def check_damage(host, guest, out):
|
||||
guest('command', command='gamemode survival')
|
||||
guest.wait(10)
|
||||
@@ -321,21 +466,21 @@ def check_hazards(host, guest, out):
|
||||
guest.wait(10)
|
||||
x, y, z = pos_of(guest)
|
||||
guest.raw('look-at', f'{x + 60:.2f}', f'{y + 1.6:.2f}', f'{z:.2f}')
|
||||
food = host.raw('lan', 'status')['guests'][0]['food']
|
||||
food = lan_guest(host)['food']
|
||||
for k in ('w', 'ctrl', 'space'):
|
||||
guest.raw('key', k, 'down')
|
||||
guest.wait(160)
|
||||
for k in ('space', 'ctrl', 'w'):
|
||||
guest.raw('key', k, 'up')
|
||||
guest.wait(10)
|
||||
fed = host.raw('lan', 'status')['guests'][0]['food']
|
||||
fed = lan_guest(host)['food']
|
||||
hunger = {'before': food, 'after': fed}
|
||||
hurt = {}
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
x, y, z = hx + 3, hy, hz
|
||||
for name, fill, ticks in (('lava', 'minecraft:lava', 60), ('wall', 'minecraft:stone', 60),
|
||||
('drowning', 'minecraft:water', 500), ('void', None, 60)):
|
||||
if remote(guest)['health'] <= 0:
|
||||
if dead(guest):
|
||||
guest.raw('respawn')
|
||||
guest.wait(40)
|
||||
# A stone box around the guest holds the lava and the water in.
|
||||
@@ -350,13 +495,20 @@ def check_hazards(host, guest, out):
|
||||
guest('command', command=f'tp @s {x + 0.5} -140 {z + 0.5}')
|
||||
hurt[name] = (before, first_hurt(guest, before, ticks))
|
||||
guest.shot(out, f'hazards-{name}')
|
||||
# A dead player's commands are not run: respawn before clearing the box,
|
||||
# and come out of the void before it kills.
|
||||
if dead(guest):
|
||||
guest.raw('respawn')
|
||||
guest.wait(40)
|
||||
elif not fill:
|
||||
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
||||
# Water puts the lava's fire out.
|
||||
guest('command', command=f'fill {x} {y} {z} {x} {y + 1} {z} minecraft:water')
|
||||
guest.wait(5)
|
||||
guest('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:air')
|
||||
guest('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y - 1} {z + 1} minecraft:stone')
|
||||
guest.wait(20)
|
||||
if remote(guest)['health'] <= 0:
|
||||
if dead(guest):
|
||||
guest.raw('respawn')
|
||||
guest.wait(40)
|
||||
# [food, saturation, exhaustion]: 8 s of sprint-jumping is about 2 of exhaustion; every 4
|
||||
@@ -369,6 +521,9 @@ def check_hazards(host, guest, out):
|
||||
|
||||
|
||||
def check_pvp(host, guest, out):
|
||||
if dead(guest):
|
||||
guest.raw('respawn')
|
||||
guest.wait(40)
|
||||
for g in (host, guest):
|
||||
g('command', command='gamemode survival')
|
||||
g('command', command='item replace entity @s weapon.mainhand with minecraft:iron_sword')
|
||||
@@ -390,7 +545,8 @@ def check_pvp(host, guest, out):
|
||||
host('command', command='gamerule pvp true')
|
||||
assert hits['guest hits host'][1] < hits['guest hits host'][0], f'the guest did not hurt the host: {hits}'
|
||||
assert hits['host hits guest'][1] < hits['host hits guest'][0], f'the host did not hurt the guest: {hits}'
|
||||
assert hits['pvp off'][1] == hits['pvp off'][0], f'pvp off still hurts: {hits}'
|
||||
# Natural regeneration may heal the victim meanwhile; only a hit lowers its health.
|
||||
assert hits['pvp off'][1] >= hits['pvp off'][0], f'pvp off still hurts: {hits}'
|
||||
return hits
|
||||
|
||||
|
||||
@@ -475,6 +631,80 @@ def check_feedback(host, guest, out):
|
||||
return chat
|
||||
|
||||
|
||||
def chat_input(game):
|
||||
return game('screen').get('chat_input')
|
||||
|
||||
|
||||
def complete(guest, text):
|
||||
"""Opens the chat on `/`, types `text` and presses Tab: the input after it."""
|
||||
guest.raw('key', 'slash', 'tap')
|
||||
guest.wait(3)
|
||||
guest.raw('type', text)
|
||||
guest.wait(2)
|
||||
guest.raw('key', 'tab', 'tap')
|
||||
guest.wait(2)
|
||||
return chat_input(guest)
|
||||
|
||||
|
||||
def check_commands(host, guest, out):
|
||||
literal = complete(guest, 'gamem')
|
||||
# The first Escape hides the suggestions, the next closes the chat.
|
||||
for _ in range(3):
|
||||
if guest('screen').get('screen') != 'chat':
|
||||
break
|
||||
guest.raw('key', 'esc', 'tap')
|
||||
guest.wait(3)
|
||||
argument = complete(guest, 'gamemode cr')
|
||||
guest.shot(out, 'commands')
|
||||
guest.raw('key', 'enter', 'tap')
|
||||
guest.wait(20)
|
||||
mode = guest('state').get('gamemode')
|
||||
screen = guest('state').get('screen')
|
||||
guest('command', command='gamemode survival')
|
||||
guest.wait(10)
|
||||
assert literal == '/gamemode', f'Tab after /gamem: {literal!r}'
|
||||
assert argument == '/gamemode creative', f'Tab after /gamemode cr: {argument!r}'
|
||||
assert mode == 'creative', f'the completed command did not run: {mode}'
|
||||
assert screen in (None, 'playing'), f'the chat did not close: {screen}'
|
||||
return {'literal': literal, 'argument': argument, 'mode': mode}
|
||||
|
||||
|
||||
def centre(rect):
|
||||
x, y, w, h = rect
|
||||
return x + w / 2, y + h / 2
|
||||
|
||||
|
||||
def grid_items(game):
|
||||
slots = game('screen').get('slots') or []
|
||||
return {s['slot']: s.get('item') for s in slots if 100 <= s['slot'] < 109 and s.get('item')}
|
||||
|
||||
|
||||
def check_recipes(host, guest, out):
|
||||
guest('command', command='clear @s')
|
||||
guest('command', command='give @s minecraft:oak_log 4')
|
||||
guest.wait(40)
|
||||
guest.raw('key', 'e', 'tap')
|
||||
guest.wait(5)
|
||||
try:
|
||||
book = guest('screen').get('recipe_book')
|
||||
assert book, 'the guest\'s inventory has no recipe book'
|
||||
if len(book['targets']) == 1:
|
||||
guest.raw('click-at', *centre(book['targets'][0]))
|
||||
guest.wait(5)
|
||||
book = guest('screen').get('recipe_book')
|
||||
known = book['known']
|
||||
assert known > 0, f'the guest\'s book is empty: {book}'
|
||||
guest.raw('click-at', *centre(book['targets'][1]))
|
||||
guest.wait(10)
|
||||
grid = grid_items(guest)
|
||||
guest.shot(out, 'recipes')
|
||||
finally:
|
||||
guest.raw('key', 'esc', 'tap')
|
||||
guest.wait(5)
|
||||
assert any('oak_log' in str(item) for item in grid.values()), f'nothing placed in the grid: {grid}'
|
||||
return {'known': known, 'grid': grid}
|
||||
|
||||
|
||||
def png_rows(path):
|
||||
"""The rows of an 8-bit RGB or RGBA PNG, unfiltered, as bytes of 4 (or 3) per pixel."""
|
||||
data = Path(path).read_bytes()
|
||||
@@ -519,7 +749,7 @@ def changed_pixels(a, b):
|
||||
def sneaking_on_host(host, want, ticks=40):
|
||||
"""Whether the host sees the guest crouching, once it matches `want` or after `ticks`."""
|
||||
for _ in range(ticks // 5):
|
||||
sneaking = host.raw('lan', 'status')['guests'][0]['sneaking']
|
||||
sneaking = lan_guest(host)['sneaking']
|
||||
if sneaking == want:
|
||||
return sneaking
|
||||
host.wait(5)
|
||||
@@ -532,7 +762,7 @@ def check_walls(host, guest, out):
|
||||
for g in (host, guest):
|
||||
back_to_game(g)
|
||||
# The trial spawner's zombies may have killed the guest.
|
||||
if guest('state').get('screen') == 'dead' or remote(guest)['health'] <= 0:
|
||||
if dead(guest):
|
||||
guest.raw('respawn')
|
||||
guest.wait(40)
|
||||
face_each_other(host, guest)
|
||||
@@ -623,14 +853,17 @@ def check_trials(host, guest, out):
|
||||
|
||||
|
||||
CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'nametags': check_nametags,
|
||||
'chest': check_chest, 'crafting': check_crafting, 'damage': check_damage,
|
||||
'chest': check_chest, 'crafting': check_crafting, 'sign': check_sign, 'damage': check_damage,
|
||||
'mobs': check_mobs, 'hazards': check_hazards, 'pvp': check_pvp,
|
||||
'containers': check_containers, 'feedback': check_feedback, 'trials': check_trials,
|
||||
'walls': check_walls}
|
||||
'containers': check_containers, 'feedback': check_feedback, 'commands': check_commands,
|
||||
'recipes': check_recipes,
|
||||
'trials': check_trials, 'walls': check_walls, 'effects': check_effects, 'portals': check_portals}
|
||||
|
||||
|
||||
def run(ctl, out, resources, checks):
|
||||
host, guest = Game('host', ctl, resources), Game('guest', ctl, resources)
|
||||
# Either way explicitly: the game's default is the server thread.
|
||||
host.d.env['HORIZON_SERVER_THREAD'] = '1' if SERVER_THREAD else '0'
|
||||
results = {}
|
||||
try:
|
||||
host('launch', wait=False, offscreen=sys.platform == 'darwin')
|
||||
@@ -676,7 +909,10 @@ def main():
|
||||
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
|
||||
parser.add_argument('--check', action='append', choices=CHECKS, help='run only these checks (repeatable)')
|
||||
parser.add_argument('--out', default=str(ROOT / '.local/lan-e2e'), help='screenshots and game logs go here')
|
||||
parser.add_argument('--server-thread', action='store_true', help="the host's world runs on its server thread")
|
||||
args = parser.parse_args()
|
||||
global SERVER_THREAD
|
||||
SERVER_THREAD = args.server_thread
|
||||
if args.bin_dir:
|
||||
bins = Path(args.bin_dir).resolve()
|
||||
else:
|
||||
|
||||
@@ -63,6 +63,14 @@ IGNORED = {
|
||||
# differently (no worldgen heightmaps, entities in entities/) is accepted.
|
||||
r'^chunk\.promoted\.(Status|Heightmaps\.(OCEAN_FLOOR_WG|WORLD_SURFACE_WG)|entities)$':
|
||||
'a proto chunk vanilla left at its view distance, finished by Horizon',
|
||||
# Single player's server thread sends the chunks its player tracks (view distance 6
|
||||
# and its outer ring), as vanilla's integrated server does, so it finishes some that
|
||||
# vanilla's server (view distance 4) left before their terrain: those had no blocks,
|
||||
# light or heightmaps to compare yet.
|
||||
r'^chunk\.blank\.':
|
||||
'a chunk vanilla left before its terrain (no blocks yet), finished by Horizon',
|
||||
r'^chunk\.unlit\.':
|
||||
'a chunk vanilla left before its light, finished (and lit) by Horizon',
|
||||
# Horizon keeps simulating between loading and saving (it needs a few seconds of
|
||||
# chunk generation first), so counters and positions of live things move on.
|
||||
r'^chunk\.block_entities\.\(2, -60, 5\)\.(cooking_time_spent|lit_time_remaining|Items\[\]\.count)$':
|
||||
@@ -459,6 +467,13 @@ def normalize_chunk(root):
|
||||
return out, sections
|
||||
|
||||
|
||||
# ChunkStatus before NOISE: nothing has filled the chunk's blocks yet.
|
||||
BLANK_STATUSES = {'minecraft:empty', 'minecraft:structure_starts', 'minecraft:structure_references',
|
||||
'minecraft:biomes'}
|
||||
# ChunkStatus LIGHT and later: the chunk's light is saved.
|
||||
LIT_STATUSES = {'minecraft:light', 'minecraft:spawn', 'minecraft:full'}
|
||||
|
||||
|
||||
def compare_chunks(vanilla, horizon, report):
|
||||
for key in sorted(set(vanilla) | set(horizon)):
|
||||
if key not in horizon:
|
||||
@@ -471,9 +486,10 @@ def compare_chunks(vanilla, horizon, report):
|
||||
ha, hs = normalize_chunk(horizon[key])
|
||||
out = []
|
||||
diff(va, ha, 'chunk', out)
|
||||
if va.get('Status') != 'minecraft:full' and ha.get('Status') == 'minecraft:full':
|
||||
promoted = re.compile(r'^chunk\.(Status|Heightmaps\.\w+_WG|entities)$')
|
||||
out = [(path.replace('chunk.', 'chunk.promoted.', 1) if promoted.match(path) else path, p, q)
|
||||
promoted = va.get('Status') != 'minecraft:full' and ha.get('Status') == 'minecraft:full'
|
||||
if promoted:
|
||||
rule = re.compile(r'^chunk\.(Status|Heightmaps\.\w+_WG|entities)$')
|
||||
out = [(path.replace('chunk.', 'chunk.promoted.', 1) if rule.match(path) else path, p, q)
|
||||
for path, p, q in out]
|
||||
for y in sorted(set(vs) | set(hs)):
|
||||
empty = {'blocks': ['minecraft:air'] * 4096, 'biomes': None, 'BlockLight': None, 'SkyLight': None}
|
||||
@@ -488,6 +504,16 @@ def compare_chunks(vanilla, horizon, report):
|
||||
if a[light] != b[light]:
|
||||
out.append((f'chunk.sections.{light}[{y}]', None if a[light] is None else 'present',
|
||||
None if b[light] is None else 'present'))
|
||||
# Before its terrain a chunk has no blocks, light or heightmaps yet.
|
||||
if promoted and va.get('Status') in BLANK_STATUSES:
|
||||
blank = re.compile(r'^chunk\.(Heightmaps|sections)\.')
|
||||
out = [(path.replace('chunk.', 'chunk.blank.', 1) if blank.match(path) else path, p, q)
|
||||
for path, p, q in out]
|
||||
# Before its light step a chunk saves no light; Horizon lights what it finishes.
|
||||
elif promoted and va.get('Status') not in LIT_STATUSES:
|
||||
unlit = re.compile(r'^chunk\.sections\.(SkyLight|BlockLight)\[')
|
||||
out = [(path.replace('chunk.', 'chunk.unlit.', 1) if unlit.match(path) else path, p, q)
|
||||
for path, p, q in out]
|
||||
for path, p, q in out:
|
||||
report.append((f'{path} @{key}', p, q))
|
||||
|
||||
|
||||
Reference in new issue
Block a user