Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 2818c89f24 Keep the command harness in the frame; compare finished border chunks
- The command harness (HORIZON_COMMAND_SCRIPT) drives the world itself,
  without the event loop, so it does not start the server thread.
- `status` lists the minecarts a client of a server was sent.
- save_roundtrip: single player's server thread finishes the chunks its
  player tracks, as vanilla's integrated server does, including some that
  vanilla's server (view distance 4) left before their terrain or light.
  Those had no blocks, light or heightmaps to compare yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 20:54:13 +02:00
Ludovic MarieandClaude Opus 5.5 9821bb7ef5 Wait for the server's block in the agent's place, try hidden spots
On a client of a server (single player's server thread, a LAN guest) the
placed block comes with the server's answer: `place` waits up to a second
for it before reporting no change. agent_control_e2e tries the next free
spot when the tree's trunk above the broken log hides a spot's faces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:34:46 +02:00
Ludovic MarieandClaude Opus 5.5 b9f3821f24 Make agents, the host window and LAN guests work on the server thread
With single player on its server thread, the window is a client of the
server, so what agents and harnesses did on the window's own state no
longer reached the world:

- control `give`, `time` and the agent's `command` run on the server as the
  world's owner (owner_console_command), in the owner's level;
- `defaultgamemode` run by a guest or the owner reaches the hub, so new
  guests join in the right mode (the hub is out while a guest acts);
- the remote client sends its held slot before using or attacking
  (MultiPlayerGameMode.ensureHasSentCarriedItem);
- remote chat lines feed the agent's event log (`wait-for chat`);
- the remote client applies another player's crouch flag, and the entity
  tracker sends Entity's shared flags and pose back at 0 (a player standing
  up again was never sent);
- a respawned player is removed from and paired again for the other
  players (their clients kept the dead body);
- a teleport drops the guest's moves sent before it, so a fall in them no
  longer hurts the guest where it lands;
- flight summaries report the server's tick time (`tick_ms`).

lan_e2e finds the guest by name in `lan status` (the owner is listed too
on the server thread), reads what the host window draws from its remote
view, respawns a dead guest by its death screen, and lets natural
regeneration heal during the pvp-off hit. agent_control_e2e waits two ticks
where the server answers a click.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 18:54:08 +02:00
Ludovic MarieandClaude Opus 5.5 4ff23b686d Run single player on its server thread by default
Single player's world now runs on the integrated server thread unless
asked otherwise, as vanilla's does, so a world tick no longer holds up
a frame. `--no-server-thread` or `HORIZON_SERVER_THREAD=0` keeps the
world in the frame as an escape hatch; `--server-thread` stays.

lan_e2e.py sets the host's mode explicitly either way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:17:12 +02:00
Ludovic Marie 30f0c715b2 Merge remote-tracking branch 'origin/feat/remote-recipe-book' into pt5/integration
# Conflicts:
#	tools/lan_e2e.py
2026-10-17 16:12:11 +02:00
Ludovic MarieandClaude Opus 5.5 a4feb65161 Show the server's recipe book on a server and place its recipes
On a server, single player's server thread included, the inventory
had no recipe book: the window ignored the recipe book packets, and
the server ignored a guest's ServerboundPlaceRecipePacket.

- horizon-protocol decodes the recipe book's packets and the recipe
  displays they carry: RecipeBookAdd / Remove / Settings,
  PlaceGhostRecipe and the serverbound PlaceRecipe. Every 26.3
  `recipe_display` and `slot_display` type is decoded (shaped,
  shapeless, furnace, stonecutter and smithing displays; empty,
  any_fuel, with_any_potion, only_with_component, item, item_stack,
  tag, dyed, smithing_trim, with_remainder and composite slots).
- The remote client keeps the book the server sends (ClientRecipeBook:
  added, highlighted, removed, replaced), shows the unlock toast,
  applies the open and filter settings, and counts the menu the
  server last sent for the craftable filter. Clicking a recipe sends
  PlaceRecipe; a ghost recipe from the server fades in.
- The server handles a guest's PlaceRecipe in the inventory grid and
  the crafting table (ServerPlaceRecipe on the menu engine, for known
  recipes only) and answers with the slots, or a ghost recipe when the
  items are missing.
- The screen state reports the recipe book's buttons.

lan_e2e.py gains a `recipes` check: the guest gets oak logs, opens the
book, clicks the planks recipe, and a log lands in the grid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:09:58 +02:00
Ludovic Marie 5f12705a29 Merge remote-tracking branch 'origin/feat/server-command-tree' into pt5/integration
# Conflicts:
#	tools/lan_e2e.py
2026-10-17 16:07:31 +02:00
Ludovic MarieandClaude Opus 5.5 e4741b7793 Decode the server's command tree and complete commands with it
The window dropped ClientboundCommandsPacket ("argument nodes are not
decoded"), so on a server, single player's server thread included,
the chat was a bare input line without completion, colouring or usage.

- horizon-protocol decodes argument nodes: the 26.3
  `command_argument_type` ids and every parser's properties
  (ArgumentTypeInfo.deserializeFromNetwork), plus the custom
  suggestion provider.
- command::network turns the tree into a Dispatcher. A node at a path
  the client's vanilla tree also has takes that node's argument type
  and suggestion provider; any other node takes the type its parser
  and properties describe.
- On a server the chat is ChatScreen over that tree, so Tab completes,
  arguments are coloured, usage and errors show, and Enter sends the
  command to the server. Player-name suggestions come from the
  server's player list. The old bare input line is gone.
- The screen state reports the chat input (`chat_input`).

lan_e2e.py gains a `commands` check: the guest completes `/gamem` to
`/gamemode` and `/gamemode cr` to `/gamemode creative` with Tab, and
the command runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:34:24 +02:00
Ludovic MarieandClaude Opus 5.5 805d5532e5 Give mob, beacon, sculk and splash effects to every player
On a server (the integrated server thread and the dedicated server)
the host has no player of its own, so effects that only touched the
host's player reached nobody; on a LAN world they reached only the
host. Beacons, the warden's and shriekers' darkness, the elder
guardian's mining fatigue, splash and lingering potions' splash and
the raid victory's Hero of the Village now run for each player in
the level, guests through `as_guest`.

The elder guardian's curse sends the guest the GUARDIAN_ELDER_EFFECT
game event, which the remote client now shows (the apparition and
the curse sound).

lan_e2e.py gains an `effects` check: a speed beacon next to both
players must give both of them speed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:04:04 +02:00
Ludovic MarieandClaude Opus 5.5 b2784363a1 Mobs see only the guests in their own level
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:49:29 +02:00
Ludovic MarieandClaude Opus 5.5 a67a323959 Server: one level per dimension, so players can be in different dimensions
A server (the dedicated server and single player's server thread) now
holds a level per dimension, like vanilla's `MinecraftServer.levels`. Each
server tick runs every level with players in it, one at a time, by swapping
its fields into place (game/levels.rs), so the world tick code runs
unchanged. Before this, a portal did nothing while two or more players were
online.

- Levels: the parked levels keep the world, streaming, save, block ticks,
  entities, block entity state, raids, sculk and creaking scans. The
  Overworld stays loaded; another level is saved and closed after 300 ticks
  with nobody in it. Saving saves every level.
- Hub: `tick` is split into `poll` (the network, once per tick),
  `tick_level` (the packets, chunks, block changes, containers and entities
  of the players in the level in place) and `end_tick` (latency). Each guest
  has a dimension; time, weather, sounds and events reach only the players
  in their level, and the clock and weather last sent are kept per level.
- Travel: every player has their own portal state; a portal moves only that
  player into the destination's level (`ServerPlayer.teleport` packets as
  before). Respawning brings a player back to the Overworld's level.
- Player data saves and loads each player's dimension, and a returning
  player is placed in their own level.
- tools/lan_e2e.py --server-thread --check portals: the guest goes to the
  Nether while the host stays, then the host goes to the End while the guest
  is in the Nether, and both come back.

The in-frame game keeps its one level that follows its player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:46:03 +02:00
Ludovic MarieandClaude Opus 5.5 45cbb4f7d6 LAN: a guest's chest, crafting table or sign no longer takes the host's mouse (#687)
A guest's action runs on the host's game with the guest's state swapped in
(`as_guest`). The swap restored the open screen but not what opening one
does to the window: `capture(false)` released the host's pointer, so the
cursor showed and the camera stopped whenever a guest opened a chest, a
crafting table or any other menu. `keys.clear()` dropped the keys the host
was holding, and a sign, book, command block or mount menu the guest opened
was drawn on the host's screen.

- `capture` leaves the window alone while a guest acts.
- `as_guest` keeps the host's held keys, sign editor, book, command block
  screen and mount menu, as it already kept the open screen.
- `state` reports `pointer_grabbed` (mouse movement turns the camera).
- tools/lan_e2e.py: `chest` and `crafting` check that the host keeps its
  screen and its mouse look; a new `sign` check places a sign as the guest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:13:46 +02:00
44 changed files with 2999 additions and 435 deletions

No files matched your search

+1
View File
@@ -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);
}
}
}
+44 -26
View File
@@ -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,
+33 -1
View File
@@ -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,
+4
View File
@@ -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;
};
+27 -6
View File
@@ -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);
+35 -15
View File
@@ -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);
}
}
+34 -7
View File
@@ -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) {
+1 -2
View File
@@ -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 {
+12 -12
View File
@@ -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))
+16 -3
View File
@@ -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()
+64 -7
View File
@@ -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(),
+60 -6
View File
@@ -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))
+65 -10
View File
@@ -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);
+7 -4
View File
@@ -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 {
+2 -2
View File
@@ -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;
+350
View File
@@ -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)
}
}
-7
View File
@@ -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)
}
+7 -11
View File
@@ -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();
+38 -28
View File
@@ -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();
+18 -7
View File
@@ -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
+122 -1
View File
@@ -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.
+109 -105
View File
@@ -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,
+12
View File
@@ -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;
}
+141 -118
View File
@@ -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>> {
+2 -2
View File
@@ -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;
};
+46 -2
View File
@@ -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());
}
}
+4
View File
@@ -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,
+127 -16
View File
@@ -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();
+5 -1
View File
@@ -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;
+9
View File
@@ -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)
+4
View File
@@ -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};
+183 -4
View File
@@ -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);
}
}
+19 -8
View File
@@ -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
View File
@@ -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:
+29 -3
View File
@@ -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))