Quiet commands: the server runs a mod's batch of commands with no chat
A Horizon client sends a client mod's batch of command lines as one `horizon:quiet_commands` payload, which Horizon's server announces only to Horizon clients. Each line runs exactly as the player typing it would (the same checks, their own permission level), with vanilla's suppressed output: no feedback and no failure in anyone's chat. Batches wait in a queue: at most 64 lines run per player per tick, and at most 4096 wait. Each finished batch is one line in the server's log. tools/quiet_commands_e2e.py checks it on the dedicated server: a non-op's quiet setblock is refused, an op's runs, and nothing reaches the chat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,7 @@ mod lan_mobs;
|
||||
mod lan_mount_menu;
|
||||
mod lan_particles;
|
||||
mod lan_pvp;
|
||||
mod lan_quiet;
|
||||
mod lan_screen;
|
||||
mod lan_stations;
|
||||
mod lan_trading;
|
||||
|
||||
@@ -85,16 +85,16 @@ fn format_double(v: f64) -> String {
|
||||
|
||||
impl Game<'_> {
|
||||
/// Commands.performCommand for a line without its slash.
|
||||
pub(super) fn run_command(&mut self, command: &str) {
|
||||
pub(super) fn run_command(&mut self, command: &str) -> bool {
|
||||
if self.mod_command(command) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
let d = vanilla::game_dispatcher();
|
||||
let parse = d.parse(command, &self.ui.registries);
|
||||
let result = match parse.executable() {
|
||||
Err(e) => {
|
||||
self.syntax_failure(&e, command);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
Ok((ctx, _)) => {
|
||||
let ctx = ctx.clone();
|
||||
@@ -103,6 +103,7 @@ impl Game<'_> {
|
||||
self.run_chain(&root, &ctx)
|
||||
}
|
||||
};
|
||||
let ok = result.is_ok();
|
||||
if let Err(e) = result {
|
||||
if e.input.is_some() {
|
||||
self.syntax_failure(&e, command);
|
||||
@@ -114,6 +115,20 @@ impl Game<'_> {
|
||||
if self.player.is_some() {
|
||||
self.check_inventory_trigger();
|
||||
}
|
||||
ok
|
||||
}
|
||||
/// `run_command` with its output suppressed
|
||||
/// (`CommandSourceStack.withSuppressedOutput`): the player's own source,
|
||||
/// rights and checks, no feedback and no failure in the chat.
|
||||
pub(super) fn run_command_quietly(&mut self, command: &str) -> bool {
|
||||
let quiet = execute::Source {
|
||||
silent: true,
|
||||
..self.player_source()
|
||||
};
|
||||
let outer = self.command_source.replace(quiet);
|
||||
let ok = self.run_command(command);
|
||||
self.command_source = outer;
|
||||
ok
|
||||
}
|
||||
fn syntax_failure(&mut self, e: &CommandError, command: &str) {
|
||||
self.command_failure(e.message.clone());
|
||||
|
||||
@@ -71,6 +71,8 @@ pub(super) struct LanHost {
|
||||
/// Guests' commands waiting for the hub to be back in `self.lan`, where
|
||||
/// a command can reach every player (game/lan_actions.rs).
|
||||
pub commands: Vec<super::lan_actions::GuestCommand>,
|
||||
/// Mods' quiet commands waiting to run a few a tick (game/lan_quiet.rs).
|
||||
pub quiet: std::collections::VecDeque<super::lan_quiet::QuietBatch>,
|
||||
/// The entities the guests track, described again every tick
|
||||
/// (`lan_entities`) over last tick's states.
|
||||
states: lan::entity::States,
|
||||
@@ -596,6 +598,7 @@ impl Game<'_> {
|
||||
waypoint_dead: Default::default(),
|
||||
border_sent: None,
|
||||
commands: Vec::new(),
|
||||
quiet: std::collections::VecDeque::new(),
|
||||
states: Default::default(),
|
||||
});
|
||||
self.audio.capture = Some(Vec::new());
|
||||
@@ -1029,6 +1032,14 @@ impl Game<'_> {
|
||||
}
|
||||
host.states = states;
|
||||
}
|
||||
if !host.quiet.is_empty() {
|
||||
self.lan = Some(host);
|
||||
self.run_quiet_commands();
|
||||
host = self
|
||||
.lan
|
||||
.take()
|
||||
.expect("the hub is back after the quiet commands");
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
|
||||
@@ -94,11 +94,14 @@ pub(super) struct GuestPlay {
|
||||
|
||||
/// A guest's command waiting for the hub (`LanHost::commands`).
|
||||
pub(super) struct GuestCommand {
|
||||
conn: u32,
|
||||
command: String,
|
||||
permission: u8,
|
||||
pub(super) conn: u32,
|
||||
pub(super) command: String,
|
||||
pub(super) permission: u8,
|
||||
/// An integrated server's: no level teleports (yet).
|
||||
integrated: bool,
|
||||
pub(super) integrated: bool,
|
||||
/// A mod's quiet command (game/lan_quiet.rs): its output suppressed, as
|
||||
/// `CommandSourceStack.withSuppressedOutput` does.
|
||||
pub(super) quiet: bool,
|
||||
}
|
||||
|
||||
/// The game's own state while a guest's is swapped in (`enter_guest`), and
|
||||
@@ -1210,6 +1213,9 @@ impl Game<'_> {
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command),
|
||||
S::CustomPayload(p) if p.channel == horizon_protocol::quiet::CHANNEL => {
|
||||
self.guest_quiet_commands(host, conn, &p.data)
|
||||
}
|
||||
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
|
||||
S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) {
|
||||
@@ -1559,6 +1565,7 @@ impl Game<'_> {
|
||||
command: command.trim_start_matches('/').to_owned(),
|
||||
permission,
|
||||
integrated,
|
||||
quiet: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1573,41 +1580,54 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let c = host.commands.remove(0);
|
||||
let mut host = self.lan.take().expect("checked above");
|
||||
let Some(swap) = self.enter_guest(&mut host, c.conn) else {
|
||||
self.lan = Some(host);
|
||||
continue;
|
||||
};
|
||||
self.lan = Some(host);
|
||||
self.chat_capture = Some(Vec::new());
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
|
||||
self.run_command(&c.command);
|
||||
self.ui.registries.permission = level;
|
||||
let lines = self.chat_capture.take().unwrap_or_default();
|
||||
let mut host = self.lan.take().expect("a command puts the hub back");
|
||||
self.leave_guest(&mut host, swap);
|
||||
host.hub.sync_all_effects();
|
||||
if !c.integrated {
|
||||
// A teleport into another level happens before its feedback is sent.
|
||||
self.run_level_teleports(&mut host);
|
||||
}
|
||||
// What the command earned is announced before its feedback.
|
||||
self.announce_guest_advancements(&mut host);
|
||||
for line in lines {
|
||||
host.hub.send_to(
|
||||
c.conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The other operators hear of it; never the player whose command it was.
|
||||
self.send_admin_feedback(&mut host, Some(c.conn));
|
||||
self.lan = Some(host);
|
||||
self.run_guest_command(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// One guest's command as that guest, its feedback sent to them; false
|
||||
/// when it failed (or the guest is gone).
|
||||
pub(super) fn run_guest_command(&mut self, c: GuestCommand) -> bool {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return false;
|
||||
};
|
||||
let Some(swap) = self.enter_guest(&mut host, c.conn) else {
|
||||
self.lan = Some(host);
|
||||
return false;
|
||||
};
|
||||
self.lan = Some(host);
|
||||
self.chat_capture = Some(Vec::new());
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
|
||||
let ok = if c.quiet {
|
||||
self.run_command_quietly(&c.command)
|
||||
} else {
|
||||
self.run_command(&c.command)
|
||||
};
|
||||
self.ui.registries.permission = level;
|
||||
let lines = self.chat_capture.take().unwrap_or_default();
|
||||
let mut host = self.lan.take().expect("a command puts the hub back");
|
||||
self.leave_guest(&mut host, swap);
|
||||
host.hub.sync_all_effects();
|
||||
if !c.integrated {
|
||||
// A teleport into another level happens before its feedback is sent.
|
||||
self.run_level_teleports(&mut host);
|
||||
}
|
||||
// What the command earned is announced before its feedback.
|
||||
self.announce_guest_advancements(&mut host);
|
||||
for line in lines {
|
||||
host.hub.send_to(
|
||||
c.conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The other operators hear of it; never the player whose command it was.
|
||||
self.send_admin_feedback(&mut host, Some(c.conn));
|
||||
self.lan = Some(host);
|
||||
ok
|
||||
}
|
||||
|
||||
/// The `broadcastToAdmins` lines a command queued (`source`: the guest
|
||||
/// whose it was; none for the host's or the console's): every other
|
||||
/// operator gets them, and the console logs them as the server's own
|
||||
|
||||
@@ -28,7 +28,11 @@ impl Game<'_> {
|
||||
|
||||
/// SignableCommand.of: the command's message arguments, by name, with
|
||||
/// their text, not past a redirect to the root (`execute ... run`).
|
||||
fn signable_arguments(&mut self, permission: u8, command: &str) -> Vec<(String, String)> {
|
||||
pub(super) fn signable_arguments(
|
||||
&mut self,
|
||||
permission: u8,
|
||||
command: &str,
|
||||
) -> Vec<(String, String)> {
|
||||
let parse = self.parse_as(permission, command);
|
||||
let nodes = &vanilla::game_dispatcher().nodes;
|
||||
let root = parse.context.root;
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Quiet commands (`horizon_protocol::quiet`): a Horizon client's mod sends a
|
||||
//! batch of command lines in one payload, and the server runs each exactly as
|
||||
//! that player typing it would (`guest_command`'s checks, the player's own
|
||||
//! permission level, at the time it runs) with its output suppressed, as
|
||||
//! vanilla's functions run (`CommandSourceStack.withSuppressedOutput`): no
|
||||
//! feedback and no failure in anyone's chat. A batch waits in a queue and runs
|
||||
//! [`PER_TICK`] lines a player a tick, so a mod can't flood a server; each
|
||||
//! finished batch is one line in the server's log.
|
||||
use super::{Game, lan::LanHost, lan_actions::GuestCommand};
|
||||
use horizon_protocol::quiet;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
/// Lines a player may have waiting at most; a batch past it loses the rest.
|
||||
pub(super) const PENDING: usize = 4096;
|
||||
/// Lines run a player a tick at most.
|
||||
pub(super) const PER_TICK: usize = 64;
|
||||
|
||||
/// A batch waiting to run.
|
||||
pub(super) struct QuietBatch {
|
||||
conn: u32,
|
||||
mod_id: String,
|
||||
lines: VecDeque<String>,
|
||||
ran: usize,
|
||||
failed: usize,
|
||||
/// Lines past [`PENDING`], never run.
|
||||
dropped: usize,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A `horizon:quiet_commands` payload from `conn`: queued (or logged as
|
||||
/// refused when it breaks the payload's caps).
|
||||
pub(super) fn guest_quiet_commands(&mut self, host: &mut LanHost, conn: u32, data: &[u8]) {
|
||||
let Some(name) = host.hub.guest(conn).map(|g| g.name.clone()) else {
|
||||
return;
|
||||
};
|
||||
let batch = match quiet::decode(data) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
super::dedicated::log(
|
||||
"WARN",
|
||||
&format!("{name} sent quiet commands that were refused: {e}"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let waiting: usize = host
|
||||
.quiet
|
||||
.iter()
|
||||
.filter(|b| b.conn == conn)
|
||||
.map(|b| b.lines.len())
|
||||
.sum();
|
||||
let room = PENDING.saturating_sub(waiting);
|
||||
let dropped = batch.lines.len().saturating_sub(room);
|
||||
host.quiet.push_back(QuietBatch {
|
||||
conn,
|
||||
mod_id: batch.mod_id,
|
||||
lines: batch.lines.into_iter().take(room).collect(),
|
||||
ran: 0,
|
||||
failed: 0,
|
||||
dropped,
|
||||
});
|
||||
}
|
||||
|
||||
/// The waiting batches, [`PER_TICK`] lines a player this tick, in order.
|
||||
pub(super) fn run_quiet_commands(&mut self) {
|
||||
let mut left: HashMap<u32, usize> = HashMap::new();
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(batch) = host.quiet.get_mut(i) else {
|
||||
return;
|
||||
};
|
||||
let conn = batch.conn;
|
||||
let budget = left.entry(conn).or_insert(PER_TICK);
|
||||
let gone = host.hub.guest(conn).is_none();
|
||||
if gone || batch.lines.is_empty() {
|
||||
let batch = host.quiet.remove(i).expect("got it above");
|
||||
if !gone {
|
||||
let name = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.map_or(String::new(), |g| g.name.clone());
|
||||
super::dedicated::log("INFO", &summary(&name, &batch));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if *budget == 0 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
*budget -= 1;
|
||||
let line = batch.lines.pop_front().expect("not empty");
|
||||
let ok = self.run_quiet_line(conn, &line);
|
||||
if let Some(batch) = self.lan.as_mut().and_then(|h| h.quiet.get_mut(i)) {
|
||||
batch.ran += 1;
|
||||
batch.failed += usize::from(!ok);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line as `guest_command` would run it for `conn`, quietly. False
|
||||
/// when it is refused or fails. A command that carries a chat message
|
||||
/// (`say`, `msg`...) is refused: it would need the player's signature.
|
||||
fn run_quiet_line(&mut self, conn: u32, line: &str) -> bool {
|
||||
let allowed = self.options.dedicated.is_some() || self.allow_commands();
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let integrated = host.hub.config.integrated;
|
||||
if !integrated && (!host.settings.guest_commands || !allowed) {
|
||||
return false;
|
||||
}
|
||||
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
|
||||
return false;
|
||||
};
|
||||
let command = line.trim_start_matches('/');
|
||||
if !self.signable_arguments(permission, command).is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.run_guest_command(GuestCommand {
|
||||
conn,
|
||||
command: command.to_owned(),
|
||||
permission,
|
||||
integrated,
|
||||
quiet: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The batch's line in the server's log.
|
||||
fn summary(name: &str, b: &QuietBatch) -> String {
|
||||
let mut line = format!(
|
||||
"{name} ran {} quiet command(s) from mod {}: {} failed",
|
||||
b.ran, b.mod_id, b.failed
|
||||
);
|
||||
if b.dropped > 0 {
|
||||
line += &format!(", {} dropped (more than {PENDING} waiting)", b.dropped);
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_log_line_counts_the_batch() {
|
||||
let b = QuietBatch {
|
||||
conn: 1,
|
||||
mod_id: "orbital".into(),
|
||||
lines: VecDeque::new(),
|
||||
ran: 12,
|
||||
failed: 3,
|
||||
dropped: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
summary("Alex", &b),
|
||||
"Alex ran 12 quiet command(s) from mod orbital: 3 failed"
|
||||
);
|
||||
let b = QuietBatch { dropped: 5, ..b };
|
||||
assert!(summary("Alex", &b).ends_with(", 5 dropped (more than 4096 waiting)"));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
//! window, waits until it stands in the world with the chunks around it,
|
||||
//! sends a chat message and `/list`, collects the chat it is shown, leaves
|
||||
//! and writes a JSON report (position, chunks, chat lines, whether the
|
||||
//! server kept it connected throughout).
|
||||
//! server kept it connected throughout). With `HORIZON_NET_QUIET` (lines
|
||||
//! split by `;`) it then sends them as one batch of a mod's quiet commands
|
||||
//! (`horizon_protocol::quiet`, `tools/quiet_commands_e2e.py`) and waits two
|
||||
//! seconds for any chat they bring.
|
||||
use super::Game;
|
||||
use horizon_client::Result;
|
||||
use serde_json::json;
|
||||
@@ -23,6 +26,9 @@ impl Game<'_> {
|
||||
let mut step_at = Instant::now();
|
||||
let mut chat_at = 0;
|
||||
let mut position = None;
|
||||
let quiet: Vec<String> = std::env::var("HORIZON_NET_QUIET")
|
||||
.map(|v| v.split(';').map(str::to_owned).collect())
|
||||
.unwrap_or_default();
|
||||
let mut chunks = 0;
|
||||
let mut error = None;
|
||||
while Instant::now() < deadline {
|
||||
@@ -71,6 +77,22 @@ impl Game<'_> {
|
||||
step_at = now;
|
||||
chat_at = shown;
|
||||
}
|
||||
2 if answered && !quiet.is_empty() => {
|
||||
for data in horizon_protocol::quiet::encode("net_harness", &quiet) {
|
||||
self.send(horizon_protocol::packets::play::Serverbound::CustomPayload(
|
||||
horizon_protocol::packets::configuration::CustomPayload {
|
||||
channel: horizon_protocol::quiet::CHANNEL.into(),
|
||||
data,
|
||||
},
|
||||
));
|
||||
}
|
||||
step = 4;
|
||||
step_at = now;
|
||||
}
|
||||
4 if now - step_at > Duration::from_secs(2) => {
|
||||
step = 3;
|
||||
break;
|
||||
}
|
||||
2 if answered => {
|
||||
step = 3;
|
||||
break;
|
||||
|
||||
@@ -823,6 +823,17 @@ fn login_and_configure(
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
// A Horizon client hears that this server runs its mods' quiet commands
|
||||
// (horizon_protocol::quiet); a vanilla one is told nothing.
|
||||
if brand == "horizon" {
|
||||
send(
|
||||
c,
|
||||
cfg::Clientbound::CustomPayload(cfg::CustomPayload {
|
||||
channel: horizon_protocol::quiet::CHANNEL.into(),
|
||||
data: Vec::new(),
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
for packet in sync.registry_packets(known_pack) {
|
||||
send(c, cfg::Clientbound::RegistryData(packet))?;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod frame;
|
||||
pub mod ids;
|
||||
pub mod item;
|
||||
pub mod packets;
|
||||
pub mod quiet;
|
||||
pub mod registry;
|
||||
mod resolver;
|
||||
pub mod rsa;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Quiet commands: a client mod's batch of commands that a Horizon server runs
|
||||
//! as the player who sent them, at that player's own permission level, with
|
||||
//! no feedback in the chat (docs/mods.md, `command.run-quiet`).
|
||||
//!
|
||||
//! The server announces it understands them with an empty [`CHANNEL`] payload
|
||||
//! in the configuration phase (a vanilla client ignores it); a client that
|
||||
//! hears it sends each batch as a serverbound [`CHANNEL`] payload, and falls
|
||||
//! back to chat commands on any other server. A payload is the mod's id, then
|
||||
//! the lines without their slash.
|
||||
use crate::{
|
||||
Error, Result,
|
||||
buf::{Reader, Writer},
|
||||
};
|
||||
|
||||
/// The custom payload channel, both ways.
|
||||
pub const CHANNEL: &str = "horizon:quiet_commands";
|
||||
/// Lines in one payload at most; a payload with more is refused whole.
|
||||
pub const MAX_LINES: usize = 256;
|
||||
/// Characters in a line at most (a chat command's limit).
|
||||
pub const MAX_LINE: usize = 256;
|
||||
/// Characters in a mod id at most.
|
||||
pub const MAX_MOD_ID: usize = 64;
|
||||
/// Bytes of a serverbound custom payload's data at most
|
||||
/// (ServerboundCustomPayloadPacket.MAX_PAYLOAD_SIZE).
|
||||
pub const MAX_PAYLOAD: usize = 32767;
|
||||
|
||||
/// A batch as the server reads it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Batch {
|
||||
pub mod_id: String,
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
/// `lines` as few payloads as fit [`MAX_PAYLOAD`] and [`MAX_LINES`], in
|
||||
/// order. Lines longer than [`MAX_LINE`] are cut, as a chat command's are.
|
||||
pub fn encode(mod_id: &str, lines: &[String]) -> Vec<Vec<u8>> {
|
||||
let mod_id: String = mod_id.chars().take(MAX_MOD_ID).collect();
|
||||
let mut payloads = Vec::new();
|
||||
let mut rest = lines
|
||||
.iter()
|
||||
.map(|l| -> String { l.chars().take(MAX_LINE).collect() });
|
||||
let mut next = rest.next();
|
||||
while next.is_some() {
|
||||
let mut batch = Vec::new();
|
||||
// The id, the count (a varint of at most 2 bytes for 256).
|
||||
let mut size = string_size(&mod_id) + 2;
|
||||
while let Some(line) = next.take() {
|
||||
let more = string_size(&line);
|
||||
if !batch.is_empty() && (batch.len() == MAX_LINES || size + more > MAX_PAYLOAD) {
|
||||
next = Some(line);
|
||||
break;
|
||||
}
|
||||
size += more;
|
||||
batch.push(line);
|
||||
next = rest.next();
|
||||
}
|
||||
let mut w = Writer::new();
|
||||
w.string(&mod_id);
|
||||
w.varint(batch.len() as i32);
|
||||
for line in &batch {
|
||||
w.string(line);
|
||||
}
|
||||
payloads.push(w.data);
|
||||
}
|
||||
payloads
|
||||
}
|
||||
|
||||
fn string_size(s: &str) -> usize {
|
||||
crate::buf::varint_size(s.len() as i32) + s.len()
|
||||
}
|
||||
|
||||
/// A payload's batch; an error for one that breaks a cap.
|
||||
pub fn decode(data: &[u8]) -> Result<Batch> {
|
||||
if data.len() > MAX_PAYLOAD {
|
||||
return Err(Error::new("quiet commands: payload too big"));
|
||||
}
|
||||
let mut r = Reader::new(data);
|
||||
let mod_id = r.string(MAX_MOD_ID)?;
|
||||
let count = r.varint()?;
|
||||
if !(0..=MAX_LINES as i32).contains(&count) {
|
||||
return Err(Error::new(format!("quiet commands: {count} lines")));
|
||||
}
|
||||
let lines = (0..count)
|
||||
.map(|_| r.string(MAX_LINE))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if r.remaining() > 0 {
|
||||
return Err(Error::new("quiet commands: trailing bytes"));
|
||||
}
|
||||
Ok(Batch { mod_id, lines })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_batch_round_trips() {
|
||||
let lines = vec!["fill 0 0 0 1 1 1 stone".to_string(), "say hi".to_string()];
|
||||
let payloads = encode("orbital", &lines);
|
||||
assert_eq!(payloads.len(), 1);
|
||||
assert_eq!(
|
||||
decode(&payloads[0]).unwrap(),
|
||||
Batch {
|
||||
mod_id: "orbital".into(),
|
||||
lines
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_batches_split_by_lines_and_bytes() {
|
||||
let lines: Vec<String> = (0..600)
|
||||
.map(|i| format!("setblock {i} 0 0 stone"))
|
||||
.collect();
|
||||
let payloads = encode("m", &lines);
|
||||
let decoded: Vec<Batch> = payloads.iter().map(|p| decode(p).unwrap()).collect();
|
||||
assert_eq!(
|
||||
decoded.iter().map(|b| b.lines.len()).collect::<Vec<_>>(),
|
||||
[256, 256, 88]
|
||||
);
|
||||
assert_eq!(
|
||||
decoded
|
||||
.into_iter()
|
||||
.flat_map(|b| b.lines)
|
||||
.collect::<Vec<_>>(),
|
||||
lines
|
||||
);
|
||||
let long: Vec<String> = (0..256).map(|_| "x".repeat(300)).collect();
|
||||
for p in encode("m", &long) {
|
||||
assert!(p.len() <= MAX_PAYLOAD);
|
||||
assert!(
|
||||
decode(&p)
|
||||
.unwrap()
|
||||
.lines
|
||||
.iter()
|
||||
.all(|l| l.len() == MAX_LINE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payloads_over_the_caps_are_refused() {
|
||||
let mut w = Writer::new();
|
||||
w.string("m");
|
||||
w.varint(MAX_LINES as i32 + 1);
|
||||
for _ in 0..=MAX_LINES {
|
||||
w.string("say");
|
||||
}
|
||||
assert!(decode(&w.data).is_err());
|
||||
let mut w = Writer::new();
|
||||
w.string("m");
|
||||
w.varint(1);
|
||||
w.string(&"x".repeat(MAX_LINE + 1));
|
||||
assert!(decode(&w.data).is_err());
|
||||
let mut w = Writer::new();
|
||||
w.string("m");
|
||||
w.varint(-1);
|
||||
assert!(decode(&w.data).is_err());
|
||||
assert!(decode(&[0xFF]).is_err());
|
||||
let mut w = Writer::new();
|
||||
w.string("m");
|
||||
w.varint(0);
|
||||
w.u8(1);
|
||||
assert!(decode(&w.data).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mods' quiet commands on Horizon's dedicated server, end to end (horizon_protocol::quiet).
|
||||
|
||||
Horizon's dedicated server (a flat world) and Horizon's headless client (`HORIZON_NET_REPORT`, the net
|
||||
harness), which joins, then sends a batch of quiet commands as a mod would (`HORIZON_NET_QUIET`):
|
||||
|
||||
- as a player who isn't an operator: `setblock` is refused (the block stays air), as it would be typed;
|
||||
- as an operator: `setblock` runs, a `fill` that changes nothing fails, and `say` is refused (a chat
|
||||
message needs the player's signature);
|
||||
- either way the player's chat gets nothing of them, and the server logs one line per batch: the player,
|
||||
the mod, how many ran and how many failed.
|
||||
|
||||
python3 tools/quiet_commands_e2e.py --bin-dir target/fast
|
||||
|
||||
The client runs with fresh HORIZON_HOME, launcher and XDG folders and without audio; the logs go to --out.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import lab # noqa: E402
|
||||
from integration_env import run_game # noqa: E402
|
||||
from test_dedicated_server_parity import Server # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PLAYER = 'Tester'
|
||||
PROPERTIES = {
|
||||
'online-mode': 'false',
|
||||
'white-list': 'false',
|
||||
'level-type': 'minecraft\\:flat',
|
||||
'generate-structures': 'false',
|
||||
'spawn-protection': '0',
|
||||
'view-distance': '3',
|
||||
'simulation-distance': '3',
|
||||
'pause-when-empty-seconds': '-1',
|
||||
}
|
||||
BLOCK = '2 -55 2'
|
||||
# What a player would see of these commands, were they typed.
|
||||
FEEDBACK = ['Changed the block', 'No blocks were filled', 'Unknown or incomplete command', 'hi from a mod',
|
||||
'Could not set the block']
|
||||
|
||||
|
||||
def join(binary, port, out, label, lines):
|
||||
"""The headless client joins, sends `lines` as one quiet batch and leaves: its report."""
|
||||
home = Path(tempfile.mkdtemp(prefix=f'quiet-{label}-'))
|
||||
report = out / f'{label}-report.json'
|
||||
# run_game gives the game fresh home, launcher and XDG folders.
|
||||
env = dict(HORIZON_NET_REPORT=str(report), HORIZON_NET_QUIET=';'.join(lines))
|
||||
with (out / f'{label}-client.log').open('w') as log:
|
||||
run_game([str(binary), '--resources', str(lab.RESOURCES), '--render-distance', '2',
|
||||
'--server', f'127.0.0.1:{port}', '--name', PLAYER],
|
||||
cwd=home, env=dict(os.environ, **env), stdout=log, stderr=subprocess.STDOUT, timeout=300,
|
||||
check=True)
|
||||
shutil.rmtree(home, ignore_errors=True)
|
||||
return json.loads(report.read_text())
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--bin-dir', help='a prebuilt horizon-client (default: build it with the fast profile)')
|
||||
parser.add_argument('--out', default=str(ROOT / '.local/quiet-commands-e2e'), help='logs go here')
|
||||
args = parser.parse_args()
|
||||
out = Path(args.out)
|
||||
shutil.rmtree(out, ignore_errors=True)
|
||||
out.mkdir(parents=True)
|
||||
if args.bin_dir:
|
||||
binary = Path(args.bin_dir) / 'horizon-client'
|
||||
else:
|
||||
subprocess.run(['cargo', 'build', '--profile', 'fast', '-p', 'horizon-client', '--bin', 'horizon-client'],
|
||||
cwd=ROOT, check=True)
|
||||
binary = Path(lab.cargo_target()) / 'fast' / 'horizon-client'
|
||||
port = lab.free_port()
|
||||
directory = out / 'server'
|
||||
directory.mkdir()
|
||||
props = dict(PROPERTIES, **{'server-port': str(port)})
|
||||
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()),
|
||||
encoding='iso-8859-1')
|
||||
(directory / 'eula.txt').write_text('eula=true\n')
|
||||
server = Server([binary, '--resources', lab.RESOURCES, '--dedicated', '--nogui'], directory, 'server')
|
||||
failures = []
|
||||
|
||||
def check(what, ok, detail=''):
|
||||
print(('PASS ' if ok else 'FAIL ') + what + ('' if ok else f': {detail}'))
|
||||
if not ok:
|
||||
failures.append(what)
|
||||
|
||||
def test_block(want):
|
||||
"""The console's `execute if block`: 'Test passed' when the block is `want`."""
|
||||
before = len(server.lines)
|
||||
server.console(f'execute if block {BLOCK} {want}')
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
answer = [l for l in server.lines[before:] if 'Test passed' in l or 'Test failed' in l]
|
||||
if answer:
|
||||
return 'Test passed' in answer[0]
|
||||
time.sleep(0.2)
|
||||
raise AssertionError('the console never answered execute if block')
|
||||
|
||||
try:
|
||||
server.wait_for(r'Done \(')
|
||||
# Not an operator: the setblock is refused, as typed.
|
||||
got = join(binary, port, out, 'not-op', [f'setblock {BLOCK} minecraft:gold_block'])
|
||||
server.wait_for(rf'{PLAYER} ran 1 quiet command\(s\) from mod net_harness: 1 failed', timeout=30)
|
||||
check('not an operator: connected throughout', got['error'] is None, got['error'])
|
||||
check("not an operator: a quiet setblock is refused", not test_block('minecraft:gold_block'))
|
||||
heard = [c for c in got['chat'] if any(f in c for f in FEEDBACK)]
|
||||
check('not an operator: nothing in the chat', not heard, heard)
|
||||
server.wait_for(rf'{PLAYER} left the game', timeout=60)
|
||||
# An operator: it runs, quietly.
|
||||
server.console(f'op {PLAYER}')
|
||||
server.wait_for(rf'Made {PLAYER} a server operator', timeout=30)
|
||||
got = join(binary, port, out, 'op', [f'setblock {BLOCK} minecraft:gold_block',
|
||||
f'fill {BLOCK} {BLOCK} minecraft:gold_block', 'say hi from a mod'])
|
||||
server.wait_for(rf'{PLAYER} ran 3 quiet command\(s\) from mod net_harness: 2 failed', timeout=30)
|
||||
check('operator: connected throughout', got['error'] is None, got['error'])
|
||||
check('operator: the quiet setblock ran', test_block('minecraft:gold_block'))
|
||||
heard = [c for c in got['chat'] if any(f in c for f in FEEDBACK)]
|
||||
check('operator: nothing in the chat (no feedback, no failure, no say)', not heard, heard)
|
||||
said = [l for l in server.lines if 'hi from a mod' in l]
|
||||
check('operator: say is refused', not said, said)
|
||||
finally:
|
||||
server.stop()
|
||||
print(f'logs: {out}')
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user