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:
Ludovic Marie
2026-10-26 13:37:04 +01:00
parent 5a9e07301b
commit 145c4c877b
11 changed files with 592 additions and 41 deletions
+1
View File
@@ -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;
+18 -3
View File
@@ -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());
+11
View File
@@ -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);
+56 -36
View File
@@ -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
+5 -1
View File
@@ -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;
+165
View File
@@ -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)"));
}
}
+23 -1
View File
@@ -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;
+11
View File
@@ -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))?;
}
+1
View File
@@ -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;
+166
View File
@@ -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());
}
}
+135
View File
@@ -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()