mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c7dbc2e09 | ||
|
|
fcfe5862ac | ||
|
|
b247769b90 | ||
|
|
25490df982 | ||
|
|
093d31721e | ||
|
|
52882c1e8c | ||
|
|
07d96de7fa | ||
|
|
c6a2010f4a |
No files matched your search
@@ -489,15 +489,36 @@ pub fn parse_duration(text: &str) -> Option<i64> {
|
||||
(count > 0).then(|| count.checked_mul(unit)).flatten()
|
||||
}
|
||||
|
||||
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let up = |unit: i64| (millis + unit - 1) / unit;
|
||||
let (count, unit) = if millis <= 60 * MINUTE {
|
||||
(up(MINUTE), "minute")
|
||||
/// The unit used when presenting a moderation duration to a player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DurationUnit {
|
||||
Minute,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
/// A duration's rounded count and unit, shared by logs and translated replies.
|
||||
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
|
||||
let (unit, kind) = if millis <= 60 * MINUTE {
|
||||
(MINUTE, DurationUnit::Minute)
|
||||
} else if millis <= 48 * 60 * MINUTE {
|
||||
(up(60 * MINUTE), "hour")
|
||||
(60 * MINUTE, DurationUnit::Hour)
|
||||
} else {
|
||||
(up(24 * 60 * MINUTE), "day")
|
||||
(24 * 60 * MINUTE, DurationUnit::Day)
|
||||
};
|
||||
(
|
||||
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
|
||||
kind,
|
||||
)
|
||||
}
|
||||
|
||||
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let (count, unit) = duration_parts(millis);
|
||||
let unit = match unit {
|
||||
DurationUnit::Minute => "minute",
|
||||
DurationUnit::Hour => "hour",
|
||||
DurationUnit::Day => "day",
|
||||
};
|
||||
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
@@ -327,3 +327,19 @@ fn an_allow_line_without_letters_allows_nothing() {
|
||||
assert!(f.matches("nigger"), "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_parts_preserve_rounding_without_overflow() {
|
||||
for (millis, expected) in [
|
||||
(1, (1, DurationUnit::Minute)),
|
||||
(60_000, (1, DurationUnit::Minute)),
|
||||
(61_000, (2, DurationUnit::Minute)),
|
||||
(3_600_000, (60, DurationUnit::Minute)),
|
||||
(3_600_001, (2, DurationUnit::Hour)),
|
||||
(172_800_000, (48, DurationUnit::Hour)),
|
||||
(172_800_001, (3, DurationUnit::Day)),
|
||||
(i64::MAX, (106_751_991_168, DurationUnit::Day)),
|
||||
] {
|
||||
assert_eq!(duration_parts(millis), expected);
|
||||
}
|
||||
}
|
||||
@@ -68,27 +68,48 @@ fn cpu_if_enabled() -> Option<Duration> {
|
||||
/// Run `f`, recording its duration under `name` when spans are enabled.
|
||||
#[inline]
|
||||
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
let _span = guard(name);
|
||||
f()
|
||||
}
|
||||
/// A span that ends where it is dropped: [`time`] for a step with several ways out (early
|
||||
/// returns). Nothing is read while spans are off.
|
||||
#[must_use = "the span ends when the guard is dropped"]
|
||||
pub struct Guard {
|
||||
open: Option<(&'static str, Instant, Option<Duration>, u8)>,
|
||||
_heap: Option<horizon_threads::heap_census::Tag>,
|
||||
}
|
||||
/// Start a span named `name` that ends when the returned [`Guard`] is dropped.
|
||||
#[inline]
|
||||
pub fn guard(name: &'static str) -> Guard {
|
||||
let _heap = horizon_threads::heap_tag(name);
|
||||
if !recording_here() {
|
||||
return f();
|
||||
return Guard { open: None, _heap };
|
||||
}
|
||||
let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1)));
|
||||
let (started, cpu) = (Instant::now(), cpu_if_enabled());
|
||||
let out = f();
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
let cpu_ms = cpu
|
||||
.and_then(|start| Some((thread_cpu()?, start)))
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
DEPTH.with(|d| d.set(depth));
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
});
|
||||
out
|
||||
Guard {
|
||||
open: Some((name, Instant::now(), cpu_if_enabled(), depth)),
|
||||
_heap,
|
||||
}
|
||||
}
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
let Some((name, started, cpu, depth)) = self.open.take() else {
|
||||
return;
|
||||
};
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
let cpu_ms = cpu
|
||||
.and_then(|start| Some((thread_cpu()?, start)))
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
DEPTH.with(|d| d.set(depth));
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
|
||||
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
|
||||
@@ -551,6 +572,42 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A guarded span ends on every way out of its step, an early return's too, and holds the
|
||||
/// spans that ran inside it until then; the next span outside it is back at its depth.
|
||||
#[test]
|
||||
fn a_guarded_span_ends_where_its_step_returns() {
|
||||
fn step(early: bool) -> u32 {
|
||||
let _span = super::guard("use_block");
|
||||
super::time("open_loot", || ());
|
||||
if early {
|
||||
return 1;
|
||||
}
|
||||
super::time("open_maps", || ());
|
||||
2
|
||||
}
|
||||
std::thread::spawn(|| {
|
||||
super::record_here(true);
|
||||
assert_eq!(step(true), 1);
|
||||
assert_eq!(step(false), 2);
|
||||
super::time("use_item", || ());
|
||||
let raw = super::take_raw();
|
||||
let seen: Vec<_> = raw.iter().map(|s| (s.name, s.depth)).collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
("open_loot", 1),
|
||||
("use_block", 0),
|
||||
("open_loot", 1),
|
||||
("open_maps", 1),
|
||||
("use_block", 0),
|
||||
("use_item", 0)
|
||||
]
|
||||
);
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A slow frame is put down to the slowest outer span, then to the span inside it that took
|
||||
/// at least half of it; repeated spans (several ticks a frame) add up.
|
||||
#[test]
|
||||
|
||||
@@ -824,7 +824,14 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
|
||||
Job::Mesh(..) => 5,
|
||||
_ => 6,
|
||||
};
|
||||
(kind, crate::spans::thread_cpu().unwrap_or_default())
|
||||
// The other jobs (saves, light views, freed meshes) are many and short: counted, not
|
||||
// timed, so the clock reads don't cost more than they do.
|
||||
let cpu = if kind == 6 {
|
||||
std::time::Duration::ZERO
|
||||
} else {
|
||||
crate::spans::thread_cpu().unwrap_or_default()
|
||||
};
|
||||
(kind, cpu)
|
||||
});
|
||||
let _tag = horizon_threads::heap_tag(match &job.job {
|
||||
Job::Generate(..) => "stream.generate",
|
||||
@@ -853,8 +860,13 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
|
||||
} else {
|
||||
kind
|
||||
};
|
||||
let cpu = crate::spans::thread_cpu().unwrap_or_default();
|
||||
job_stats::add(kind, cpu.saturating_sub(started).as_nanos() as u64);
|
||||
let nanos = if kind == 6 {
|
||||
0
|
||||
} else {
|
||||
let cpu = crate::spans::thread_cpu().unwrap_or_default();
|
||||
cpu.saturating_sub(started).as_nanos() as u64
|
||||
};
|
||||
job_stats::add(kind, nanos);
|
||||
}
|
||||
if let Some(done) = done {
|
||||
self.shared.done.send(done);
|
||||
|
||||
@@ -346,5 +346,34 @@
|
||||
"horizon.extras.cosmetics_auras.tooltip": "Auren und Spuren, die andere Spieler tragen. Standardmäßig aus.",
|
||||
"horizon.extras.cosmetics.shown": "Angezeigt",
|
||||
"horizon.extras.cosmetics.hidden": "Ausgeblendet",
|
||||
"horizon.extras.cosmetics.name_tags": "Nur Namensschilder"
|
||||
"horizon.extras.cosmetics.name_tags": "Nur Namensschilder",
|
||||
"horizon.moderation.duration.minute": "%s Minute",
|
||||
"horizon.moderation.duration.minutes": "%s Minuten",
|
||||
"horizon.moderation.duration.hour": "%s Stunde",
|
||||
"horizon.moderation.duration.hours": "%s Stunden",
|
||||
"horizon.moderation.duration.day": "%s Tag",
|
||||
"horizon.moderation.duration.days": "%s Tage",
|
||||
"horizon.moderation.reason": "%1$s: %2$s",
|
||||
"horizon.moderation.mutelist.empty": "Keine Spieler stummgeschaltet",
|
||||
"horizon.moderation.mutelist.count": "Stummgeschaltete Spieler: %s",
|
||||
"horizon.moderation.chat_filter": "dem Chatfilter",
|
||||
"horizon.moderation.until_unmuted": "bis zur Aufhebung der Stummschaltung",
|
||||
"horizon.moderation.remaining": "für weitere %s",
|
||||
"horizon.moderation.mutelist.entry": "%1$s wurde von %2$s %3$s stummgeschaltet",
|
||||
"horizon.moderation.invalid_duration": "%s ist keine Zeitdauer: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <Spieler> [Dauer] [Grund]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <Spieler> <Dauer> [Grund]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <Spieler>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
"horizon.moderation.for_duration": "für %s",
|
||||
"horizon.moderation.muted": "%1$s wurde %2$s stummgeschaltet",
|
||||
"horizon.moderation.unmuted": "%s ist nicht mehr stummgeschaltet",
|
||||
"horizon.moderation.not_muted": "%s ist nicht stummgeschaltet",
|
||||
"horizon.tempban.usage": "/tempban <Spieler> <Dauer> [Grund]",
|
||||
"horizon.tempban.invalid_duration": "%s ist keine Zeitdauer: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Temporäre Sperren benötigen einen dedizierten Server",
|
||||
"horizon.tempban.success": "%1$s für %2$s, bis %3$s",
|
||||
"horizon.tempban.list.empty": "Es gibt keine befristeten Sperren",
|
||||
"horizon.tempban.list.count": "Es gibt %s befristete Sperre(n):",
|
||||
"horizon.tempban.list.entry": "%1$s wurde von %2$s gesperrt, %3$s (bis %4$s)"
|
||||
}
|
||||
@@ -522,5 +522,34 @@
|
||||
"horizon.extras.cosmetics_auras.tooltip": "Auras and trails other players wear. Off by default.",
|
||||
"horizon.extras.cosmetics.shown": "Shown",
|
||||
"horizon.extras.cosmetics.hidden": "Hidden",
|
||||
"horizon.extras.cosmetics.name_tags": "Name tags only"
|
||||
"horizon.extras.cosmetics.name_tags": "Name tags only",
|
||||
"horizon.moderation.duration.minute": "%s minute",
|
||||
"horizon.moderation.duration.minutes": "%s minutes",
|
||||
"horizon.moderation.duration.hour": "%s hour",
|
||||
"horizon.moderation.duration.hours": "%s hours",
|
||||
"horizon.moderation.duration.day": "%s day",
|
||||
"horizon.moderation.duration.days": "%s days",
|
||||
"horizon.moderation.reason": "%1$s: %2$s",
|
||||
"horizon.moderation.mutelist.empty": "There are no mutes",
|
||||
"horizon.moderation.mutelist.count": "There are %s mute(s):",
|
||||
"horizon.moderation.chat_filter": "the chat filter",
|
||||
"horizon.moderation.until_unmuted": "until unmuted",
|
||||
"horizon.moderation.remaining": "for another %s",
|
||||
"horizon.moderation.mutelist.entry": "%1$s was muted by %2$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s isn't a duration: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <player>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
"horizon.moderation.for_duration": "for %s",
|
||||
"horizon.moderation.muted": "Muted %1$s %2$s",
|
||||
"horizon.moderation.unmuted": "Unmuted %s",
|
||||
"horizon.moderation.not_muted": "%s isn't muted",
|
||||
"horizon.tempban.usage": "/tempban <player> <duration> [reason]",
|
||||
"horizon.tempban.invalid_duration": "%s isn't a duration: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Temporary bans need a dedicated server",
|
||||
"horizon.tempban.success": "%1$s for %2$s, until %3$s",
|
||||
"horizon.tempban.list.empty": "There are no temporary bans",
|
||||
"horizon.tempban.list.count": "There are %s temporary ban(s):",
|
||||
"horizon.tempban.list.entry": "%1$s was banned by %2$s %3$s (until %4$s)"
|
||||
}
|
||||
@@ -228,5 +228,34 @@
|
||||
"How frames reach the screen, and the performance overlay.": "Cómo llegan los fotogramas a la pantalla, y la superposición de rendimiento.",
|
||||
"Brighter-than-white output on an HDR display.": "Salida más brillante que el blanco en una pantalla HDR.",
|
||||
"Results for \"%s\"": "Resultados de \"%s\"",
|
||||
"No settings match.": "Ningún ajuste coincide."
|
||||
"No settings match.": "Ningún ajuste coincide.",
|
||||
"horizon.moderation.duration.minute": "%s minuto",
|
||||
"horizon.moderation.duration.minutes": "%s minutos",
|
||||
"horizon.moderation.duration.hour": "%s hora",
|
||||
"horizon.moderation.duration.hours": "%s horas",
|
||||
"horizon.moderation.duration.day": "%s día",
|
||||
"horizon.moderation.duration.days": "%s días",
|
||||
"horizon.moderation.reason": "%1$s: %2$s",
|
||||
"horizon.moderation.mutelist.empty": "No hay jugadores silenciados",
|
||||
"horizon.moderation.mutelist.count": "Jugadores silenciados: %s",
|
||||
"horizon.moderation.chat_filter": "el filtro del chat",
|
||||
"horizon.moderation.until_unmuted": "hasta que se retire el silencio",
|
||||
"horizon.moderation.remaining": "durante %s más",
|
||||
"horizon.moderation.mutelist.entry": "%1$s fue silenciado por %2$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s no es una duración: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <jugador> [duración] [motivo]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <jugador> <duración> [motivo]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <jugador>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
"horizon.moderation.for_duration": "durante %s",
|
||||
"horizon.moderation.muted": "Se ha silenciado a %1$s %2$s",
|
||||
"horizon.moderation.unmuted": "%s ya no está silenciado",
|
||||
"horizon.moderation.not_muted": "%s no está silenciado",
|
||||
"horizon.tempban.usage": "/tempban <jugador> <duración> [motivo]",
|
||||
"horizon.tempban.invalid_duration": "%s no es una duración: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Los bloqueos temporales necesitan un servidor dedicado",
|
||||
"horizon.tempban.success": "%1$s durante %2$s, hasta %3$s",
|
||||
"horizon.tempban.list.empty": "No hay baneos temporales",
|
||||
"horizon.tempban.list.count": "Hay %s baneo(s) temporal(es):",
|
||||
"horizon.tempban.list.entry": "%1$s fue baneado por %2$s %3$s (hasta %4$s)"
|
||||
}
|
||||
@@ -443,5 +443,34 @@
|
||||
"horizon.repro.bundle": "Archive de reproduction : %s",
|
||||
"horizon.repro.error": "Reproduction : %s",
|
||||
"horizon.repro.no_recording": "Il n'y a pas encore d'enregistrement à exporter.",
|
||||
"horizon.repro.replay_started": "Lecture démarrée : %s"
|
||||
"horizon.repro.replay_started": "Lecture démarrée : %s",
|
||||
"horizon.moderation.duration.minute": "%s minute",
|
||||
"horizon.moderation.duration.minutes": "%s minutes",
|
||||
"horizon.moderation.duration.hour": "%s heure",
|
||||
"horizon.moderation.duration.hours": "%s heures",
|
||||
"horizon.moderation.duration.day": "%s jour",
|
||||
"horizon.moderation.duration.days": "%s jours",
|
||||
"horizon.moderation.reason": "%1$s : %2$s",
|
||||
"horizon.moderation.mutelist.empty": "Aucun joueur réduit au silence",
|
||||
"horizon.moderation.mutelist.count": "Nombre de joueurs réduits au silence : %s",
|
||||
"horizon.moderation.chat_filter": "le filtre de discussion",
|
||||
"horizon.moderation.until_unmuted": "jusqu'au rétablissement de la parole",
|
||||
"horizon.moderation.remaining": "pour encore %s",
|
||||
"horizon.moderation.mutelist.entry": "%1$s a été réduit au silence par %2$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s n'est pas une durée : 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <joueur> [durée] [raison]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <joueur> <durée> [raison]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <joueur>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
"horizon.moderation.for_duration": "pour %s",
|
||||
"horizon.moderation.muted": "%1$s a été réduit au silence %2$s",
|
||||
"horizon.moderation.unmuted": "%s peut de nouveau parler",
|
||||
"horizon.moderation.not_muted": "%s n'est pas réduit au silence",
|
||||
"horizon.tempban.usage": "/tempban <joueur> <durée> [raison]",
|
||||
"horizon.tempban.invalid_duration": "%s n'est pas une durée : 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Les bannissements temporaires nécessitent un serveur dédié",
|
||||
"horizon.tempban.success": "%1$s pour %2$s, jusqu'au %3$s",
|
||||
"horizon.tempban.list.empty": "Il n'y a aucun bannissement temporaire",
|
||||
"horizon.tempban.list.count": "Il y a %s bannissement(s) temporaire(s) :",
|
||||
"horizon.tempban.list.entry": "%1$s a été banni par %2$s %3$s (jusqu'au %4$s)"
|
||||
}
|
||||
@@ -331,6 +331,7 @@ mod test_environments;
|
||||
mod test_instance_edit;
|
||||
mod test_instances;
|
||||
mod text_displays;
|
||||
mod tick_profile;
|
||||
mod titles;
|
||||
mod tnt;
|
||||
mod totem;
|
||||
@@ -5720,13 +5721,15 @@ impl Game<'_> {
|
||||
// ShulkerBoxBlock.canOpen: the lid has no room; nothing opens.
|
||||
} else if let Some(entity) = BlockEntity::for_block(&name) {
|
||||
self.world.block_entities.get_or_insert_with(key, || entity);
|
||||
self.unpack_opened_loot(key, &name);
|
||||
horizon_client::spans::time("open_loot", || self.unpack_opened_loot(key, &name));
|
||||
let centre = [
|
||||
f64::from(key.0) + 0.5,
|
||||
f64::from(key.1) + 0.5,
|
||||
f64::from(key.2) + 0.5,
|
||||
];
|
||||
self.resolve_container_maps(key, centre);
|
||||
horizon_client::spans::time("open_maps", || {
|
||||
self.resolve_container_maps(key, centre);
|
||||
});
|
||||
self.container = Some(key);
|
||||
let first = self.container_opener_event(key, true);
|
||||
self.post_block_game_event(
|
||||
@@ -6374,9 +6377,13 @@ impl Game<'_> {
|
||||
let mut anchor_passed = false;
|
||||
// Native mods, where ServerPlayerGameMode.useItemOn starts: a
|
||||
// canceled click does nothing (a guest's block is sent back).
|
||||
// A slow tick's line names the phase of a use that took it (#8669): the mods' hook,
|
||||
// the block's own use, then the item's.
|
||||
if let Some(hit) = block_hit {
|
||||
let mut canceled = false;
|
||||
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
|
||||
horizon_client::spans::time("use_mods", || {
|
||||
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
|
||||
});
|
||||
if canceled {
|
||||
// Nothing was used, so nothing consumed the action.
|
||||
self.use_failed = true;
|
||||
@@ -6384,6 +6391,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// MultiPlayerGameMode.useItemOn: blocks react first.
|
||||
let block_phase = horizon_client::spans::guard("use_block");
|
||||
if !(sneaking && hands_full)
|
||||
&& let Some(hit) = block_hit
|
||||
{
|
||||
@@ -6566,6 +6574,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(block_phase);
|
||||
let _item_phase = horizon_client::spans::guard("use_item");
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
@@ -11,53 +11,66 @@ use horizon_protocol::text::{literal, translatable};
|
||||
use horizon_server_files::user_lists::{BanEntry, NameAndId};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// One line that `/mute`, `/tempmute`, `/mutelist`, `/unmute` and `/tempban` answer with: Horizon's own words,
|
||||
/// or vanilla's message where vanilla has one, which each client shows in its language.
|
||||
/// A moderation reply retains its translation component for each client's language.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) enum Reply {
|
||||
Line(String),
|
||||
/// A vanilla translation key with no argument.
|
||||
Vanilla(&'static str),
|
||||
}
|
||||
pub(super) struct Reply(Text);
|
||||
|
||||
impl Reply {
|
||||
/// `EntityArgument.NO_PLAYERS_FOUND`, `argument.entity.notfound.player`: what `/kick` and the other
|
||||
/// player commands answer for a name no player has.
|
||||
pub(super) fn translated(key: &str, args: &[Text]) -> Self {
|
||||
Self(Self::translation(key, args))
|
||||
}
|
||||
|
||||
/// Vanilla clients lack Horizon's keys, so preserve a readable fallback too.
|
||||
pub(super) fn translation(key: &str, args: &[Text]) -> Text {
|
||||
if key.starts_with("horizon.") {
|
||||
static ENGLISH: std::sync::OnceLock<horizon_client::lang::Language> =
|
||||
std::sync::OnceLock::new();
|
||||
let english =
|
||||
ENGLISH.get_or_init(|| horizon_client::lang::Language::load(&[], "en_us"));
|
||||
Text::translate_with_fallback(key, english.get(key).unwrap_or(key), args)
|
||||
} else {
|
||||
Text::translate(key, args)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn no_player() -> Self {
|
||||
Self::Vanilla("argument.entity.notfound.player")
|
||||
Self::translated("argument.entity.notfound.player", &[])
|
||||
}
|
||||
|
||||
/// `BanPlayerCommands.ERROR_ALREADY_BANNED`: `commands.ban.failed`.
|
||||
pub(super) fn already_banned() -> Self {
|
||||
Self::Vanilla("commands.ban.failed")
|
||||
Self::translated("commands.ban.failed", &[])
|
||||
}
|
||||
|
||||
pub(super) fn duration(millis: i64) -> Text {
|
||||
use horizon_chat_filter::DurationUnit;
|
||||
let (count, unit) = horizon_chat_filter::duration_parts(millis);
|
||||
let key = match (unit, count == 1) {
|
||||
(DurationUnit::Minute, true) => "horizon.moderation.duration.minute",
|
||||
(DurationUnit::Minute, false) => "horizon.moderation.duration.minutes",
|
||||
(DurationUnit::Hour, true) => "horizon.moderation.duration.hour",
|
||||
(DurationUnit::Hour, false) => "horizon.moderation.duration.hours",
|
||||
(DurationUnit::Day, true) => "horizon.moderation.duration.day",
|
||||
(DurationUnit::Day, false) => "horizon.moderation.duration.days",
|
||||
};
|
||||
Self::translation(key, &[Text::literal(count.to_string())])
|
||||
}
|
||||
|
||||
pub(super) fn with_reason(self, reason: Option<&str>) -> Self {
|
||||
match reason {
|
||||
Some(reason) => Self::translated(
|
||||
"horizon.moderation.reason",
|
||||
&[self.0, Text::literal(reason)],
|
||||
),
|
||||
None => self,
|
||||
}
|
||||
}
|
||||
|
||||
/// The chat component a player is sent.
|
||||
pub(super) fn tag(&self) -> Tag {
|
||||
match self {
|
||||
Self::Line(line) => literal(line),
|
||||
Self::Vanilla(key) => translatable(key, &[]),
|
||||
}
|
||||
horizon_client::lan::data::component_nbt(&self.0.component())
|
||||
}
|
||||
|
||||
/// The text the console prints.
|
||||
pub(super) fn text(&self) -> Text {
|
||||
match self {
|
||||
Self::Line(line) => Text::literal(line.clone()),
|
||||
Self::Vanilla(key) => Text::translate(key, &[]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Reply {
|
||||
fn from(line: String) -> Self {
|
||||
Self::Line(line)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Reply {
|
||||
fn from(line: &str) -> Self {
|
||||
Self::Line(line.to_owned())
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +80,26 @@ const MUTE_LEVEL: u8 = 3;
|
||||
/// The commands of `mute_command`.
|
||||
pub(super) const MUTE_COMMANDS: [&str; 4] = ["mute", "tempmute", "mutelist", "unmute"];
|
||||
|
||||
/// The mute commands as operators' clients get them in the command tree (`command_tree`'s usages).
|
||||
pub(super) const MUTE_USAGES: [&str; 4] = [
|
||||
"mute <player:player> [duration_and_reason:text]",
|
||||
"tempmute <player:player> <duration:word> [reason:text]",
|
||||
"mutelist",
|
||||
"unmute <player:player>",
|
||||
];
|
||||
|
||||
/// `/mute <player> [duration] [reason]` (`words`, the command first): how long, when the word after
|
||||
/// the name is a duration (`10m`, `2h`, `1d`; none: until `/unmute`), and the index the reason starts at.
|
||||
fn mute_time(words: &[&str]) -> (Option<i64>, usize) {
|
||||
match words
|
||||
.get(2)
|
||||
.and_then(|w| horizon_chat_filter::parse_duration(w))
|
||||
{
|
||||
Some(ms) => (Some(ms), 3),
|
||||
None => (None, 2),
|
||||
}
|
||||
}
|
||||
|
||||
/// The reason a temporary ban gives at login.
|
||||
const BAN_REASON: &str = "Hate speech in chat (Horizon chat filter)";
|
||||
|
||||
@@ -251,33 +284,52 @@ impl Game<'_> {
|
||||
let now = horizon_chat_filter::now_millis();
|
||||
let mutes = host.hub.mutes();
|
||||
if mutes.is_empty() {
|
||||
return vec!["There are no mutes".into()];
|
||||
return vec![Reply::translated("horizon.moderation.mutelist.empty", &[])];
|
||||
}
|
||||
let mut lines = vec![format!("There are {} mute(s):", mutes.len())];
|
||||
let mut lines = vec![Reply::translated(
|
||||
"horizon.moderation.mutelist.count",
|
||||
&[Text::literal(mutes.len().to_string())],
|
||||
)];
|
||||
for m in mutes {
|
||||
let by = m.by.as_deref().unwrap_or("the chat filter");
|
||||
let by = m.by.as_deref().map_or_else(
|
||||
|| Reply::translation("horizon.moderation.chat_filter", &[]),
|
||||
Text::literal,
|
||||
);
|
||||
let until = m.until.map_or_else(
|
||||
|| "until unmuted".into(),
|
||||
|| Reply::translation("horizon.moderation.until_unmuted", &[]),
|
||||
|u| {
|
||||
let left = horizon_chat_filter::duration_text(u - now);
|
||||
format!("for {}", left.replacen(' ', " more ", 1))
|
||||
Reply::translation(
|
||||
"horizon.moderation.remaining",
|
||||
&[Reply::duration(u - now)],
|
||||
)
|
||||
},
|
||||
);
|
||||
let why = m.reason.map(|r| format!(": {r}")).unwrap_or_default();
|
||||
lines.push(format!("{} was muted by {by} {until}{why}", m.name));
|
||||
lines.push(
|
||||
Reply::translated(
|
||||
"horizon.moderation.mutelist.entry",
|
||||
&[Text::literal(m.name), by, until],
|
||||
)
|
||||
.with_reason(m.reason.as_deref()),
|
||||
);
|
||||
}
|
||||
lines.into_iter().map(Reply::from).collect()
|
||||
lines
|
||||
}
|
||||
["unmute", name] => vec![self.unmute(host, name, by)],
|
||||
["mute", name, ..] => {
|
||||
let (time, from) = mute_time(words);
|
||||
vec![self.mute(host, name, time, reason(from), by)]
|
||||
}
|
||||
["unmute", name] => vec![self.unmute(host, name, by).into()],
|
||||
["mute", name, ..] => vec![self.mute(host, name, None, reason(2), by)],
|
||||
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
|
||||
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
|
||||
None => vec![format!("{time} isn't a duration: 10m, 2h, 1d").into()],
|
||||
None => vec![Reply::translated(
|
||||
"horizon.moderation.invalid_duration",
|
||||
&[Text::literal(*time)],
|
||||
)],
|
||||
},
|
||||
["mute", ..] => vec!["/mute <player> [reason]".into()],
|
||||
["tempmute", ..] => vec!["/tempmute <player> <duration> [reason]".into()],
|
||||
["unmute", ..] => vec!["/unmute <player>".into()],
|
||||
_ => vec!["/mutelist".into()],
|
||||
["mute", ..] => vec![Reply::translated("horizon.moderation.usage.mute", &[])],
|
||||
["tempmute", ..] => vec![Reply::translated("horizon.moderation.usage.tempmute", &[])],
|
||||
["unmute", ..] => vec![Reply::translated("horizon.moderation.usage.unmute", &[])],
|
||||
_ => vec![Reply::translated("horizon.moderation.usage.mutelist", &[])],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,26 +362,31 @@ impl Game<'_> {
|
||||
.as_deref()
|
||||
.map(|r| format!(": {r}"))
|
||||
.unwrap_or_default();
|
||||
host.hub.mute(uuid, &name, until, by, reason);
|
||||
host.hub.mute(uuid, &name, until, by, reason.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
dedicated::log(
|
||||
"INFO",
|
||||
&format!("[chat-filter] {by} muted {name} {what}{why}"),
|
||||
);
|
||||
}
|
||||
format!("Muted {name} {what}{why}").into()
|
||||
let until = time.map_or_else(
|
||||
|| Reply::translation("horizon.moderation.until_unmuted", &[]),
|
||||
|ms| Reply::translation("horizon.moderation.for_duration", &[Reply::duration(ms)]),
|
||||
);
|
||||
Reply::translated("horizon.moderation.muted", &[Text::literal(name), until])
|
||||
.with_reason(reason.as_deref())
|
||||
}
|
||||
|
||||
/// Lifts `name`'s mute, logged; what to answer.
|
||||
pub(super) fn unmute(&mut self, host: &mut LanHost, name: &str, by: &str) -> String {
|
||||
pub(super) fn unmute(&mut self, host: &mut LanHost, name: &str, by: &str) -> Reply {
|
||||
match host.hub.unmute(name) {
|
||||
Some(name) => {
|
||||
if self.options.dedicated.is_some() {
|
||||
dedicated::log("INFO", &format!("[chat-filter] {by} unmuted {name}"));
|
||||
}
|
||||
format!("Unmuted {name}")
|
||||
Reply::translated("horizon.moderation.unmuted", &[Text::literal(name)])
|
||||
}
|
||||
None => format!("{name} isn't muted"),
|
||||
None => Reply::translated("horizon.moderation.not_muted", &[Text::literal(name)]),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,10 +407,131 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizons_own_lines_stay_literal() {
|
||||
let reply = Reply::from("Muted Steve until unmuted");
|
||||
assert_eq!(reply.tag(), literal("Muted Steve until unmuted"));
|
||||
assert_eq!(reply.text().translation_key(), None);
|
||||
assert_eq!(reply.text().plain(), "Muted Steve until unmuted");
|
||||
fn horizons_replies_keep_nested_translation_components() {
|
||||
let reply = Reply::translated(
|
||||
"horizon.moderation.muted",
|
||||
&[
|
||||
Text::literal("Steve"),
|
||||
Reply::translation(
|
||||
"horizon.moderation.for_duration",
|
||||
&[Reply::duration(61_000)],
|
||||
),
|
||||
],
|
||||
)
|
||||
.with_reason(Some("literal reason"));
|
||||
let component = reply.text().component();
|
||||
assert_eq!(component["translate"], "horizon.moderation.reason");
|
||||
assert_eq!(
|
||||
component["with"][0]["translate"],
|
||||
"horizon.moderation.muted"
|
||||
);
|
||||
assert_eq!(
|
||||
component["with"][0]["with"][1]["with"][0]["translate"],
|
||||
"horizon.moderation.duration.minutes"
|
||||
);
|
||||
assert_eq!(component["with"][1]["text"], "literal reason");
|
||||
assert_eq!(
|
||||
reply.tag(),
|
||||
horizon_client::lan::data::component_nbt(&component)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clients_can_translate_nested_replies_or_use_the_fallback() {
|
||||
use horizon_client::lang::{self, Language};
|
||||
lang::scoped(Language::load(&[], "en_us"), || {
|
||||
let reply = Reply::translated(
|
||||
"horizon.moderation.muted",
|
||||
&[
|
||||
Text::literal("Steve"),
|
||||
Reply::translation(
|
||||
"horizon.moderation.for_duration",
|
||||
&[Reply::duration(61_000)],
|
||||
),
|
||||
],
|
||||
)
|
||||
.with_reason(Some("reason %s"));
|
||||
let wire = reply.tag();
|
||||
assert_eq!(
|
||||
horizon_protocol::text::plain(&wire, &Default::default()),
|
||||
"Muted Steve for 2 minutes: reason %s"
|
||||
);
|
||||
let language = [
|
||||
(
|
||||
"horizon.moderation.reason".to_owned(),
|
||||
"%1$s (%2$s)".to_owned(),
|
||||
),
|
||||
(
|
||||
"horizon.moderation.muted".to_owned(),
|
||||
"M %2$s %1$s".to_owned(),
|
||||
),
|
||||
(
|
||||
"horizon.moderation.for_duration".to_owned(),
|
||||
"D %s".to_owned(),
|
||||
),
|
||||
(
|
||||
"horizon.moderation.duration.minutes".to_owned(),
|
||||
"%s min_local".to_owned(),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
horizon_protocol::text::plain(&wire, &language),
|
||||
"M D 2 min_local Steve (reason %s)"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_languages_resolve_the_reply_on_the_receiving_client() {
|
||||
use horizon_client::lang::{self, Language};
|
||||
lang::scoped(Language::load(&[], "en_us"), || {
|
||||
let reply = Reply::translated(
|
||||
"horizon.moderation.muted",
|
||||
&[
|
||||
Text::literal("Steve"),
|
||||
Reply::translation(
|
||||
"horizon.moderation.for_duration",
|
||||
&[Reply::duration(61_000)],
|
||||
),
|
||||
],
|
||||
)
|
||||
.with_reason(Some("reason %s"));
|
||||
for (code, expected) in [
|
||||
(
|
||||
"fr_fr",
|
||||
"Steve a été réduit au silence pour 2 minutes : reason %s",
|
||||
),
|
||||
(
|
||||
"de_de",
|
||||
"Steve wurde für 2 Minuten stummgeschaltet: reason %s",
|
||||
),
|
||||
(
|
||||
"es_es",
|
||||
"Se ha silenciado a Steve durante 2 minutos: reason %s",
|
||||
),
|
||||
] {
|
||||
let language = Language::load(&[], code);
|
||||
assert_eq!(
|
||||
horizon_protocol::text::plain(&reply.tag(), language.map()),
|
||||
expected,
|
||||
"{code}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mute_takes_a_duration_after_the_name_or_mutes_until_unmuted() {
|
||||
assert_eq!(mute_time(&["mute", "Steve"]), (None, 2));
|
||||
assert_eq!(mute_time(&["mute", "Steve", "spamming", "chat"]), (None, 2));
|
||||
assert_eq!(
|
||||
mute_time(&["mute", "Steve", "1h", "spam"]),
|
||||
(Some(3_600_000), 3)
|
||||
);
|
||||
assert_eq!(mute_time(&["mute", "Steve", "30m"]), (Some(1_800_000), 3));
|
||||
// Not a duration: the word starts the reason.
|
||||
assert_eq!(mute_time(&["mute", "Steve", "0m", "spam"]), (None, 2));
|
||||
}
|
||||
}
|
||||
@@ -968,6 +968,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let uuid = self.level.player_uuid;
|
||||
let level_id = self.dim.id();
|
||||
let mut snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
|
||||
let mut events = Vec::new();
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
@@ -1103,7 +1104,12 @@ impl Game<'_> {
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
horizon_client::mobs::blocks_changed(m, &path_changes);
|
||||
let from = events.len();
|
||||
// `horizon-report`'s cost by mob type (tick_profile.rs).
|
||||
let sampled = super::tick_profile::start().map(|t| (t, m.kind.clone()));
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
if let Some((started, kind)) = sampled {
|
||||
super::tick_profile::add(level_id, &kind, started);
|
||||
}
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
// death run (`LivingEntity.remove` with KILLED).
|
||||
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
|
||||
|
||||
@@ -1243,9 +1243,16 @@ impl Game<'_> {
|
||||
let ticked =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
match &mut state.report {
|
||||
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
|
||||
recording.add(wall, cpu, spans)
|
||||
}),
|
||||
Some(recording) => {
|
||||
let sampled = recording.sample_next();
|
||||
super::tick_profile::set_sampling(sampled);
|
||||
let ticked =
|
||||
self.tick_server_spans(paused, |wall, cpu, spans, recorded| {
|
||||
recording.add(wall, cpu, spans, recorded, sampled)
|
||||
});
|
||||
super::tick_profile::set_sampling(false);
|
||||
ticked
|
||||
}
|
||||
None => self.tick_server_timed(paused),
|
||||
}
|
||||
}));
|
||||
@@ -1512,6 +1519,17 @@ impl Game<'_> {
|
||||
state.lists.lock().unwrap().stop = true;
|
||||
}
|
||||
// Horizon's own, on the console only: a performance report (report.rs).
|
||||
"horizon-report" if rest.first() == Some(&"stop") => match state.report.take() {
|
||||
Some(recording) => {
|
||||
log("INFO", "Stopping the performance report: writing it now");
|
||||
self.finish_report(recording, state.view, state.max_players);
|
||||
}
|
||||
None => log("INFO", "No performance report is being recorded"),
|
||||
},
|
||||
"horizon-report" if rest.first() == Some(&"status") => match &state.report {
|
||||
Some(recording) => log("INFO", &recording.status()),
|
||||
None => log("INFO", "No performance report is being recorded"),
|
||||
},
|
||||
"horizon-report" => {
|
||||
let seconds = match rest.first().map(|s| s.parse::<u32>()) {
|
||||
None => Ok(60),
|
||||
@@ -1522,7 +1540,7 @@ impl Game<'_> {
|
||||
(Err(()), _) => log(
|
||||
"INFO",
|
||||
&format!(
|
||||
"horizon-report [SECONDS]: 1 to {} seconds, 60 by default",
|
||||
"horizon-report [SECONDS | stop | status]: 1 to {} seconds, 60 by default",
|
||||
report::MAX_SECONDS
|
||||
),
|
||||
),
|
||||
@@ -1530,7 +1548,7 @@ impl Game<'_> {
|
||||
log("INFO", "A performance report is already being recorded")
|
||||
}
|
||||
(Ok(seconds), None) => {
|
||||
state.report = Some(report::Recording::start(seconds));
|
||||
state.report = Some(report::Recording::start(seconds, self.wire_bytes()));
|
||||
log(
|
||||
"INFO",
|
||||
&format!("Recording a performance report for {seconds} s (reports/)"),
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
//! `horizon-report [SECONDS]` on the dedicated server's console (and `horizon-server report`): records every
|
||||
//! tick's time and spans for a window (60 s unless told), then writes a performance report to share,
|
||||
//! `reports/perf-<time>.json` and a short `.txt` beside it in the server's folder. It says where the ticks'
|
||||
//! time went (the spans of `HORIZON_SPIKES`), shows the worst ticks with their spans, counts the entities by
|
||||
//! type and the chunks of each level, and gives the memory, the CPU and the view settings. It holds no player
|
||||
//! names, UUIDs or addresses, no seed and no world name.
|
||||
use super::super::{Dim, Game};
|
||||
use horizon_client::spans::{self, Span};
|
||||
//! tick's time and spans for a window (60 s unless told; `horizon-report stop` ends it early, `horizon-report
|
||||
//! status` says how far it is), then writes a performance report to share, `reports/perf-<time>.json` and a
|
||||
//! short `.txt` beside it in the server's folder. It says where the ticks' time went (the spans of
|
||||
//! `HORIZON_SPIKES`, each level's share), what each mob type costs (sampled, tick_profile.rs), what the chunk
|
||||
//! workers spent their CPU on (load, generate, decorate, light...), the network bytes per player, and what
|
||||
//! recording all this cost the tick. It shows the worst ticks with their spans, counts the entities by type and
|
||||
//! the chunks of each level, and gives the memory, the CPU and the view settings. It holds no player names,
|
||||
//! UUIDs or addresses, no seed and no world name. It runs beside the game: nothing restarts.
|
||||
use super::super::{Dim, Game, tick_profile};
|
||||
use horizon_client::{
|
||||
spans::{self, Span},
|
||||
streaming::job_stats,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
@@ -17,6 +23,8 @@ use std::{
|
||||
pub(super) const MAX_SECONDS: u32 = 600;
|
||||
/// The worst ticks a report lists with their spans.
|
||||
const WORST: usize = 10;
|
||||
/// The mob types (per level) a report lists with their cost.
|
||||
const MOB_TYPES: usize = 40;
|
||||
|
||||
struct Tick {
|
||||
wall_ms: f32,
|
||||
@@ -32,30 +40,64 @@ pub(super) struct Recording {
|
||||
/// Whether spans were on before (`HORIZON_SPIKES`): they stay on after.
|
||||
spans_were_on: bool,
|
||||
ticks: Vec<Tick>,
|
||||
/// The ticks whose mobs were timed one by one ([`tick_profile::SAMPLE_EVERY`]).
|
||||
sampled_ticks: u64,
|
||||
/// The spans recorded, before they are summed by name: what timing them cost.
|
||||
spans_recorded: u64,
|
||||
/// Each player's wire bytes (in, out) by connection when the recording started.
|
||||
wire_start: BTreeMap<u32, (u64, u64)>,
|
||||
}
|
||||
|
||||
impl Recording {
|
||||
pub(super) fn start(seconds: u32) -> Self {
|
||||
pub(super) fn start(seconds: u32, wire: Vec<(u32, (u64, u64))>) -> Self {
|
||||
let spans_were_on = spans::enabled();
|
||||
spans::enable(true);
|
||||
job_stats::enable(true);
|
||||
job_stats::take();
|
||||
tick_profile::take();
|
||||
Self {
|
||||
seconds,
|
||||
started: Instant::now(),
|
||||
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
|
||||
spans_were_on,
|
||||
ticks: Vec::with_capacity(seconds as usize * 20),
|
||||
sampled_ticks: 0,
|
||||
spans_recorded: 0,
|
||||
wire_start: wire.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
pub(super) fn add(&mut self, wall_ms: f32, cpu_ms: Option<f32>, spans: Vec<Span>) {
|
||||
/// Whether the next tick's mobs are timed one by one.
|
||||
pub(super) fn sample_next(&self) -> bool {
|
||||
self.ticks.len().is_multiple_of(tick_profile::SAMPLE_EVERY)
|
||||
}
|
||||
pub(super) fn add(
|
||||
&mut self,
|
||||
wall_ms: f32,
|
||||
cpu_ms: Option<f32>,
|
||||
spans: Vec<Span>,
|
||||
recorded: usize,
|
||||
sampled: bool,
|
||||
) {
|
||||
self.ticks.push(Tick {
|
||||
wall_ms,
|
||||
cpu_ms,
|
||||
spans,
|
||||
});
|
||||
self.sampled_ticks += u64::from(sampled);
|
||||
self.spans_recorded += recorded as u64;
|
||||
}
|
||||
pub(super) fn done(&self) -> bool {
|
||||
self.started.elapsed() >= Duration::from_secs(u64::from(self.seconds))
|
||||
}
|
||||
/// `horizon-report status`: how far the recording is.
|
||||
pub(super) fn status(&self) -> String {
|
||||
format!(
|
||||
"Recording a performance report: {} of {} s, {} ticks (horizon-report stop writes it now)",
|
||||
self.started.elapsed().as_secs(),
|
||||
self.seconds,
|
||||
self.ticks.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn pct(sorted: &[f32], q: f64) -> f64 {
|
||||
@@ -130,6 +172,126 @@ fn tick_summary(r: &Recording, window_s: f64) -> (Value, Value, Value) {
|
||||
(ticks, spans, worst)
|
||||
}
|
||||
|
||||
/// The sampled mob costs: per level and type, the milliseconds a tick, the mobs ticking, and the
|
||||
/// microseconds one mob costs. Costliest first, the first [`MOB_TYPES`].
|
||||
fn mob_costs(costs: &[tick_profile::MobCost], sampled_ticks: u64) -> Value {
|
||||
let ticks = sampled_ticks.max(1) as f64;
|
||||
Value::Array(
|
||||
costs
|
||||
.iter()
|
||||
.take(MOB_TYPES)
|
||||
.map(|c| {
|
||||
json!({
|
||||
"dimension": c.dimension,
|
||||
"type": c.kind,
|
||||
"ms_per_tick": round(c.nanos as f64 / ticks / 1e6),
|
||||
"mobs_ticked": round(c.mob_ticks as f64 / ticks),
|
||||
"us_per_mob": round(c.nanos as f64 / c.mob_ticks.max(1) as f64 / 1e3),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The chunk workers' CPU time by job kind (`streaming::job_stats`): cores used, jobs, ms a job.
|
||||
fn worker_jobs(jobs: &[(&'static str, u64, u64)], window_s: f64) -> Value {
|
||||
Value::Array(
|
||||
jobs.iter()
|
||||
.filter(|(_, _, count)| *count > 0)
|
||||
.map(|&(kind, nanos, count)| {
|
||||
json!({
|
||||
"kind": kind,
|
||||
"jobs": count,
|
||||
"jobs_per_second": round(count as f64 / window_s.max(1e-9)),
|
||||
"cpu_cores": round(nanos as f64 / 1e9 / window_s.max(1e-9)),
|
||||
"ms_per_job": round(nanos as f64 / count as f64 / 1e6),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The players' network use over the window, from the wire bytes of the players on at both ends
|
||||
/// (in KiB a second: average, P50, P95, max), and what all players on at the end sent since the
|
||||
/// start (or since they joined).
|
||||
fn network(start: &BTreeMap<u32, (u64, u64)>, end: &[(u32, (u64, u64))], window_s: f64) -> Value {
|
||||
let kib_s = |bytes: u64| bytes as f64 / 1024.0 / window_s.max(1e-9);
|
||||
let mut out = Vec::new();
|
||||
let mut inn = Vec::new();
|
||||
let (mut total_out, mut total_in) = (0u64, 0u64);
|
||||
for (conn, (now_in, now_out)) in end {
|
||||
let (was_in, was_out) = start.get(conn).copied().unwrap_or((0, 0));
|
||||
let (d_in, d_out) = (
|
||||
now_in.saturating_sub(was_in),
|
||||
now_out.saturating_sub(was_out),
|
||||
);
|
||||
total_in += d_in;
|
||||
total_out += d_out;
|
||||
if start.contains_key(conn) {
|
||||
out.push(kib_s(d_out) as f32);
|
||||
inn.push(kib_s(d_in) as f32);
|
||||
}
|
||||
}
|
||||
let stats = |v: &mut Vec<f32>| {
|
||||
v.sort_by(f32::total_cmp);
|
||||
let avg = v.iter().fold(0.0, |sum, &x| sum + f64::from(x)) / v.len().max(1) as f64;
|
||||
json!({
|
||||
"average": round(avg),
|
||||
"p50": round(pct(v, 0.5)),
|
||||
"p95": round(pct(v, 0.95)),
|
||||
"max": round(v.last().copied().map_or(0.0, f64::from)),
|
||||
})
|
||||
};
|
||||
json!({
|
||||
"players_whole_window": out.len(),
|
||||
"out_kib_per_s_per_player": stats(&mut out),
|
||||
"in_kib_per_s_per_player": stats(&mut inn),
|
||||
"total_out_kib_per_s": round(kib_s(total_out)),
|
||||
"total_in_kib_per_s": round(kib_s(total_in)),
|
||||
})
|
||||
}
|
||||
|
||||
/// What recording cost the tick, priced on this machine at the end of the recording: every
|
||||
/// sampled mob two clock reads, every span inside the tick two clocks and two thread-CPU clocks.
|
||||
/// A dedicated server records spans whenever `HORIZON_SPIKES` is on (by default, for its slow-tick
|
||||
/// line): then they are not the report's cost, and `ms_per_tick` leaves them out.
|
||||
fn overhead(r: &Recording, mob_samples: u64, span_ns: f64, mob_ns: f64) -> Value {
|
||||
let n = r.ticks.len().max(1) as f64;
|
||||
let mspt = r.ticks.iter().map(|t| f64::from(t.wall_ms)).sum::<f64>() / n;
|
||||
let spans_per_tick = r.spans_recorded as f64 / n;
|
||||
let mobs_per_tick = mob_samples as f64 / n;
|
||||
let spans_ms = spans_per_tick * span_ns / 1e6;
|
||||
let mobs_ms = mobs_per_tick * mob_ns / 1e6;
|
||||
let ms = mobs_ms + if r.spans_were_on { 0.0 } else { spans_ms };
|
||||
let small = |v: f64| (v * 1e4).round() / 1e4;
|
||||
json!({
|
||||
"span_ns": round(span_ns),
|
||||
"spans_per_tick": round(spans_per_tick),
|
||||
"spans_ms_per_tick": small(spans_ms),
|
||||
"spans_on_without_report": r.spans_were_on,
|
||||
"mob_sample_ns": round(mob_ns),
|
||||
"mob_samples_per_tick": round(mobs_per_tick),
|
||||
"mob_samples_ms_per_tick": small(mobs_ms),
|
||||
"ms_per_tick": small(ms),
|
||||
"share_of_mspt": small(ms / mspt.max(1e-9)),
|
||||
})
|
||||
}
|
||||
|
||||
/// What one span costs on this thread with spans on: the report's own overhead. The spans it
|
||||
/// records are thrown away.
|
||||
fn span_cost_nanos() -> f64 {
|
||||
const N: u32 = 5_000;
|
||||
let kept = spans::take_raw();
|
||||
let timer = Instant::now();
|
||||
for _ in 0..N {
|
||||
drop(spans::guard("calibration"));
|
||||
}
|
||||
let per = timer.elapsed().as_nanos() as f64 / f64::from(N);
|
||||
spans::take_raw();
|
||||
debug_assert!(kept.is_empty(), "the tick takes its spans");
|
||||
per
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The level in place and the parked ones: their chunks and their entities by type.
|
||||
fn report_levels(&self) -> Value {
|
||||
@@ -162,31 +324,63 @@ impl Game<'_> {
|
||||
if let (Some(world), Some(mobs)) =
|
||||
(self.levels.parked_world(dim), self.levels.parked_mobs(dim))
|
||||
{
|
||||
levels.push(here(dim, world, mobs));
|
||||
let mut level = here(dim, world, mobs);
|
||||
if let Some([items, orbs, projectiles, carts, boats]) =
|
||||
self.levels.parked_entity_counts(dim)
|
||||
{
|
||||
level["item_entities"] = json!(items);
|
||||
level["experience_orbs"] = json!(orbs);
|
||||
level["projectiles"] = json!(projectiles);
|
||||
level["minecarts"] = json!(carts);
|
||||
level["boats"] = json!(boats);
|
||||
}
|
||||
levels.push(level);
|
||||
}
|
||||
}
|
||||
Value::Array(levels)
|
||||
}
|
||||
|
||||
/// Each player's wire bytes (in, out) by connection.
|
||||
pub(super) fn wire_bytes(&self) -> Vec<(u32, (u64, u64))> {
|
||||
self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter_map(|g| Some((g.conn, g.sender.wire_bytes()?)))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// The report of a finished recording: written to `reports/` in the server's folder, its summary logged.
|
||||
pub(super) fn finish_report(&mut self, r: Recording, view: (u32, u32), max_players: usize) {
|
||||
let span_ns = span_cost_nanos();
|
||||
let mob_ns = tick_profile::sample_cost_nanos();
|
||||
if !r.spans_were_on {
|
||||
spans::enable(false);
|
||||
}
|
||||
job_stats::enable(false);
|
||||
let window_s = r.started.elapsed().as_secs_f64();
|
||||
let (ticks, spans, worst) = tick_summary(&r, window_s);
|
||||
let costs = tick_profile::take();
|
||||
let mob_samples = costs.iter().map(|c| c.mob_ticks).sum();
|
||||
let jobs = job_stats::take();
|
||||
let cpu = horizon_bench::cpu_seconds(std::process::id())
|
||||
.zip(r.cpu_s)
|
||||
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
|
||||
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
|
||||
let report = json!({
|
||||
"format": 1,
|
||||
"format": 2,
|
||||
"horizon": horizon_client::crash::version(),
|
||||
"minecraft": horizon_core::VANILLA_VERSION,
|
||||
"window_s": round(window_s),
|
||||
"ticks": ticks,
|
||||
"spans": spans,
|
||||
"worst_ticks": worst,
|
||||
"mob_costs": mob_costs(&costs, r.sampled_ticks),
|
||||
"mob_sampled_ticks": r.sampled_ticks,
|
||||
"chunk_workers": worker_jobs(&jobs, window_s),
|
||||
"network": network(&r.wire_start, &self.wire_bytes(), window_s),
|
||||
"overhead": overhead(&r, mob_samples, span_ns, mob_ns),
|
||||
"players": players,
|
||||
"levels": self.report_levels(),
|
||||
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
@@ -233,22 +427,79 @@ fn summary(r: &Value) -> String {
|
||||
t["over_50_ms"],
|
||||
t["over_100_ms"],
|
||||
);
|
||||
let spans: Vec<String> = r["spans"]
|
||||
let span_list = |levels: bool, n: usize| -> Vec<String> {
|
||||
r["spans"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|s| s["name"].as_str().unwrap_or("").starts_with("level_") == levels)
|
||||
.take(n)
|
||||
.map(|s| {
|
||||
format!(
|
||||
"{} {} ms",
|
||||
s["name"].as_str().unwrap_or(""),
|
||||
s["ms_per_tick"]
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
out += &format!("per tick: {}\n", span_list(false, 5).join(", "));
|
||||
let levels = span_list(true, 3);
|
||||
if !levels.is_empty() {
|
||||
out += &format!("levels: {}\n", levels.join(", "));
|
||||
}
|
||||
if let Some(w) = r["worst_ticks"].get(0) {
|
||||
out += &format!("worst tick {} ms: {}\n", w["ms"], w["spans"]);
|
||||
}
|
||||
let costs: Vec<String> = r["mob_costs"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.take(5)
|
||||
.map(|s| {
|
||||
.map(|c| {
|
||||
format!(
|
||||
"{} {} ms",
|
||||
s["name"].as_str().unwrap_or(""),
|
||||
s["ms_per_tick"]
|
||||
"{} {} ms ({} x {} us)",
|
||||
c["type"].as_str().unwrap_or(""),
|
||||
c["ms_per_tick"],
|
||||
c["mobs_ticked"],
|
||||
c["us_per_mob"]
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out += &format!("per tick: {}\n", spans.join(", "));
|
||||
if let Some(w) = r["worst_ticks"].get(0) {
|
||||
out += &format!("worst tick {} ms: {}\n", w["ms"], w["spans"]);
|
||||
if !costs.is_empty() {
|
||||
out += &format!("mobs: {}\n", costs.join(", "));
|
||||
}
|
||||
let workers: Vec<String> = r["chunk_workers"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|w| {
|
||||
format!(
|
||||
"{} {}/s {} cores",
|
||||
w["kind"].as_str().unwrap_or(""),
|
||||
w["jobs_per_second"],
|
||||
w["cpu_cores"]
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if !workers.is_empty() {
|
||||
out += &format!("chunk workers: {}\n", workers.join(", "));
|
||||
}
|
||||
let net = &r["network"];
|
||||
if net.is_object() {
|
||||
out += &format!(
|
||||
"network: {} KiB/s out in all, {} KiB/s a player (P95 {}, max {})\n",
|
||||
net["total_out_kib_per_s"],
|
||||
net["out_kib_per_s_per_player"]["average"],
|
||||
net["out_kib_per_s_per_player"]["p95"],
|
||||
net["out_kib_per_s_per_player"]["max"]
|
||||
);
|
||||
}
|
||||
if let Some(o) = r["overhead"].as_object() {
|
||||
out += &format!(
|
||||
"recording cost {} ms a tick ({} of MSPT)\n",
|
||||
o["ms_per_tick"], o["share_of_mspt"]
|
||||
);
|
||||
}
|
||||
let mobs: u64 = r["levels"]
|
||||
.as_array()
|
||||
@@ -339,15 +590,23 @@ mod tests {
|
||||
cpu_s: None,
|
||||
spans_were_on: true,
|
||||
ticks: Vec::new(),
|
||||
sampled_ticks: 0,
|
||||
spans_recorded: 0,
|
||||
wire_start: BTreeMap::new(),
|
||||
};
|
||||
for i in 0..20 {
|
||||
let wall = if i == 7 { 120.0 } else { 10.0 };
|
||||
let sampled = r.sample_next();
|
||||
r.add(
|
||||
wall,
|
||||
Some(wall - 1.0),
|
||||
vec![span("tick_mobs", wall / 2.0), span("tick_lan", 2.0)],
|
||||
2,
|
||||
sampled,
|
||||
);
|
||||
}
|
||||
assert_eq!(r.sampled_ticks, 5, "one tick in four");
|
||||
assert_eq!(r.spans_recorded, 40);
|
||||
let (ticks, spans, worst) = tick_summary(&r, 1.0);
|
||||
assert_eq!(ticks["count"], 20);
|
||||
assert_eq!(ticks["tps"], 20.0);
|
||||
@@ -385,4 +644,143 @@ mod tests {
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A mob type's sampled time is spread over the sampled ticks; one mob's cost over its ticks.
|
||||
#[test]
|
||||
fn mob_costs_are_per_sampled_tick_and_per_mob() {
|
||||
let costs = [
|
||||
tick_profile::MobCost {
|
||||
dimension: "minecraft:overworld",
|
||||
kind: "minecraft:villager".into(),
|
||||
// 10 sampled ticks of 200 villagers at 25 us each.
|
||||
nanos: 10 * 200 * 25_000,
|
||||
mob_ticks: 10 * 200,
|
||||
},
|
||||
tick_profile::MobCost {
|
||||
dimension: "minecraft:the_end",
|
||||
kind: "minecraft:enderman".into(),
|
||||
nanos: 10 * 40 * 2_000,
|
||||
mob_ticks: 10 * 40,
|
||||
},
|
||||
];
|
||||
let v = mob_costs(&costs, 10);
|
||||
assert_eq!(v[0]["type"], "minecraft:villager");
|
||||
assert_eq!(v[0]["ms_per_tick"], 5.0);
|
||||
assert_eq!(v[0]["mobs_ticked"], 200.0);
|
||||
assert_eq!(v[0]["us_per_mob"], 25.0);
|
||||
assert_eq!(v[1]["dimension"], "minecraft:the_end");
|
||||
assert_eq!(v[1]["ms_per_tick"], 0.08);
|
||||
assert_eq!(mob_costs(&[], 0), json!([]));
|
||||
}
|
||||
|
||||
/// Per-player rates count only the players on for the whole window; the total also counts
|
||||
/// those who joined during it, from zero.
|
||||
#[test]
|
||||
fn network_rates_per_player_and_in_all() {
|
||||
let start = BTreeMap::from([(1, (0, 0)), (2, (5_000, 50_000)), (9, (1, 1))]);
|
||||
let end = [
|
||||
(1, (10 * 1024, 100 * 1024 * 10)),
|
||||
(2, (5_000 + 20 * 1024, 50_000 + 300 * 1024 * 10)),
|
||||
// Joined during the window.
|
||||
(3, (0, 600 * 1024 * 10)),
|
||||
];
|
||||
let v = network(&start, &end, 10.0);
|
||||
assert_eq!(v["players_whole_window"], 2);
|
||||
assert_eq!(v["out_kib_per_s_per_player"]["average"], 200.0);
|
||||
assert_eq!(v["out_kib_per_s_per_player"]["max"], 300.0);
|
||||
assert_eq!(v["in_kib_per_s_per_player"]["average"], 1.5);
|
||||
assert_eq!(v["total_out_kib_per_s"], 1000.0);
|
||||
}
|
||||
|
||||
/// Worker kinds that ran no job are left out; CPU time over the window is cores.
|
||||
#[test]
|
||||
fn worker_jobs_give_cores_and_job_times() {
|
||||
let jobs = [
|
||||
("load", 0, 0),
|
||||
("generate", 30_000_000_000, 6_000),
|
||||
("light", 2_000_000_000, 4_000),
|
||||
];
|
||||
let v = worker_jobs(&jobs, 60.0);
|
||||
assert_eq!(v.as_array().unwrap().len(), 2);
|
||||
assert_eq!(v[0]["kind"], "generate");
|
||||
assert_eq!(v[0]["cpu_cores"], 0.5);
|
||||
assert_eq!(v[0]["jobs_per_second"], 100.0);
|
||||
assert_eq!(v[0]["ms_per_job"], 5.0);
|
||||
assert_eq!(v[1]["ms_per_job"], 0.5);
|
||||
}
|
||||
|
||||
/// The recording's cost: spans and mob samples a tick at their measured price, against MSPT.
|
||||
#[test]
|
||||
fn overhead_is_measured_against_the_tick() {
|
||||
let mut r = Recording {
|
||||
seconds: 1,
|
||||
started: Instant::now(),
|
||||
cpu_s: None,
|
||||
spans_were_on: true,
|
||||
ticks: Vec::new(),
|
||||
sampled_ticks: 0,
|
||||
spans_recorded: 0,
|
||||
wire_start: BTreeMap::new(),
|
||||
};
|
||||
for _ in 0..4 {
|
||||
r.add(50.0, None, Vec::new(), 100, false);
|
||||
}
|
||||
// Spans were off before: 100 spans at 200 ns + 1,000 mob samples a tick at 50 ns = 0.07 ms
|
||||
// of 50.
|
||||
r.spans_were_on = false;
|
||||
let v = overhead(&r, 4_000, 200.0, 50.0);
|
||||
assert_eq!(v["spans_ms_per_tick"], 0.02);
|
||||
assert_eq!(v["ms_per_tick"], 0.07);
|
||||
assert_eq!(v["share_of_mspt"], 0.0014);
|
||||
// Spans on anyway (a dedicated server's slow-tick line): only the mob samples are the report's.
|
||||
r.spans_were_on = true;
|
||||
let v = overhead(&r, 4_000, 200.0, 50.0);
|
||||
assert_eq!(v["ms_per_tick"], 0.05);
|
||||
assert_eq!(v["share_of_mspt"], 0.001);
|
||||
// On this machine, both prices are well under a microsecond's order.
|
||||
let (span_ns, mob_ns) = (span_cost_nanos(), tick_profile::sample_cost_nanos());
|
||||
assert!(span_ns > 0.0 && span_ns < 50_000.0, "{span_ns}");
|
||||
assert!(mob_ns > 0.0 && mob_ns < 50_000.0, "{mob_ns}");
|
||||
}
|
||||
|
||||
/// The summary lists the levels apart from the systems, and the new sections when present.
|
||||
#[test]
|
||||
fn summary_lists_levels_mobs_workers_network_and_cost() {
|
||||
let r = json!({
|
||||
"ticks": {"tps": 20.0, "mspt": {"average": 40.0, "p50": 39.0, "p95": 45.0, "p99": 50.0, "max": 60.0},
|
||||
"over_50_ms": 1, "over_100_ms": 0},
|
||||
"spans": [{"name": "level_overworld", "ms_per_tick": 30.0}, {"name": "tick_mobs", "ms_per_tick": 20.0},
|
||||
{"name": "level_the_end", "ms_per_tick": 9.0}],
|
||||
"worst_ticks": [],
|
||||
"mob_costs": [{"type": "minecraft:villager", "ms_per_tick": 5.0, "mobs_ticked": 200.0, "us_per_mob": 25.0}],
|
||||
"chunk_workers": [{"kind": "generate", "jobs_per_second": 100.0, "cpu_cores": 0.5}],
|
||||
"network": {"total_out_kib_per_s": 1000.0, "out_kib_per_s_per_player": {"average": 200.0, "p95": 300.0, "max": 300.0}},
|
||||
"overhead": {"ms_per_tick": 0.07, "share_of_mspt": 0.0014},
|
||||
"players": 2, "levels": [], "memory": {"rss_mb": 1}, "cpu_cores_used": 1.0,
|
||||
});
|
||||
let text = summary(&r);
|
||||
assert!(text.contains("per tick: tick_mobs 20.0 ms\n"), "{text}");
|
||||
assert!(
|
||||
text.contains("levels: level_overworld 30.0 ms, level_the_end 9.0 ms\n"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("mobs: minecraft:villager 5.0 ms (200.0 x 25.0 us)\n"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("chunk workers: generate 100.0/s 0.5 cores\n"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(
|
||||
"network: 1000.0 KiB/s out in all, 200.0 KiB/s a player (P95 300.0, max 300.0)"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("recording cost 0.07 ms a tick (0.0014 of MSPT)"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1840,7 +1840,8 @@ impl Game<'_> {
|
||||
for event in events {
|
||||
if timed {
|
||||
note_event(&host, previous.take());
|
||||
previous = Some((Instant::now(), event_kind(&event)));
|
||||
let detail = self.event_detail(&host, &event);
|
||||
previous = Some((Instant::now(), event_kind(&event), detail));
|
||||
}
|
||||
match event {
|
||||
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
|
||||
@@ -2287,7 +2288,7 @@ impl Game<'_> {
|
||||
// this tick to their clients. Otherwise a newly broken block and its
|
||||
// drops remain absent from the snapshot until the following tick.
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
horizon_client::spans::time("lan_guests", || self.tick_guests(&mut host));
|
||||
// The vehicles the guests just moved enter their portals now.
|
||||
let ridden: Vec<u32> = host.hub.guests.iter().filter_map(|g| g.vehicle).collect();
|
||||
self.find_ridden_vehicle_portals(&ridden);
|
||||
@@ -2304,9 +2305,11 @@ impl Game<'_> {
|
||||
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
|
||||
.collect();
|
||||
self.lan_riding(&guests, &mut host.hub.riding);
|
||||
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, true, 0);
|
||||
self.lan_hook_states(&host.play, &host.hub, &mut states);
|
||||
self.lan_entity_uuids(&mut states);
|
||||
horizon_client::spans::time("lan_entity_states", || {
|
||||
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, true, 0);
|
||||
self.lan_hook_states(&host.play, &host.hub, &mut states);
|
||||
self.lan_entity_uuids(&mut states);
|
||||
});
|
||||
host.states_next_entity = Some(self.next_entity);
|
||||
} else {
|
||||
host.states_next_entity = None;
|
||||
@@ -2402,14 +2405,19 @@ impl Game<'_> {
|
||||
if !pass.packets_drained {
|
||||
events.extend(host.hub.drain_packets(&mut ctx));
|
||||
}
|
||||
events.extend(host.hub.tick_level(&mut ctx));
|
||||
events.extend(horizon_client::spans::time("lan_tick_level", || {
|
||||
host.hub.tick_level(&mut ctx)
|
||||
}));
|
||||
if pass.last {
|
||||
host.hub.end_tick();
|
||||
horizon_client::spans::time("lan_end_tick", || host.hub.end_tick());
|
||||
}
|
||||
events
|
||||
};
|
||||
self.wake_woken_guests(&mut host);
|
||||
let Some(back) = self.lan_process_events(host, events, entities, spawn) else {
|
||||
let processed = horizon_client::spans::time("lan_process_events", || {
|
||||
self.lan_process_events(host, events, entities, spawn)
|
||||
});
|
||||
let Some(back) = processed else {
|
||||
return;
|
||||
};
|
||||
host = back;
|
||||
@@ -3060,22 +3068,73 @@ fn event_kind(event: &HostEvent) -> EventKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes how long the event that started at `started` took (`spans::note`).
|
||||
fn note_event(host: &LanHost, started: Option<(Instant, EventKind)>) {
|
||||
let Some((started, (kind, conn, name))) = started else {
|
||||
/// Notes how long the event that started at `started` took (`spans::note`), with what it
|
||||
/// was about when [`Game::event_detail`] said.
|
||||
fn note_event(host: &LanHost, started: Option<(Instant, EventKind, Option<UseDetail>)>) {
|
||||
let Some((started, (kind, conn, name), detail)) = started else {
|
||||
return;
|
||||
};
|
||||
horizon_client::spans::note(started.elapsed().as_secs_f32() * 1000.0, || {
|
||||
let who = name
|
||||
.or_else(|| conn.and_then(|c| host.hub.guest(c)).map(|g| g.name.clone()))
|
||||
.or_else(|| conn.map(|c| format!("connection {c}")));
|
||||
match who {
|
||||
let line = match who {
|
||||
Some(who) => format!("{kind} of {who}"),
|
||||
None => kind.to_owned(),
|
||||
};
|
||||
match detail {
|
||||
Some(d) => format!("{line} ({d})"),
|
||||
None => line,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What a use packet was about, taken before it ran (the use may consume the item or change
|
||||
/// the block): the item in the hand it names and, on a block, that block, where, and the face.
|
||||
struct UseDetail {
|
||||
item: Option<Arc<str>>,
|
||||
block: Option<(Arc<str>, [i32; 3], i32)>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UseDetail {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.item.as_deref().unwrap_or("empty hand"))?;
|
||||
if let Some((block, [x, y, z], face)) = &self.block {
|
||||
let face = ["down", "up", "north", "south", "west", "east"]
|
||||
.get(*face as usize)
|
||||
.copied()
|
||||
.unwrap_or("?");
|
||||
write!(f, " on {block} at {x} {y} {z} {face}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// For a slow tick's note (#8669): the item a `UseItemOn` or `UseItem` uses and the block
|
||||
/// it aims at. None for other events.
|
||||
fn event_detail(&self, host: &LanHost, event: &HostEvent) -> Option<UseDetail> {
|
||||
let HostEvent::Packet(conn, packet) = event else {
|
||||
return None;
|
||||
};
|
||||
let (hand, block) = match &**packet {
|
||||
play::Serverbound::UseItemOn(p) => (p.hand, Some((p.pos, p.face))),
|
||||
play::Serverbound::UseItem(p) => (p.hand, None),
|
||||
_ => return None,
|
||||
};
|
||||
let guest = host.hub.guest(*conn)?;
|
||||
let held = if hand == 1 {
|
||||
guest.offhand.as_ref()
|
||||
} else {
|
||||
guest.inventory.get(guest.selected).and_then(Option::as_ref)
|
||||
};
|
||||
Some(UseDetail {
|
||||
item: held.map(|s| Arc::clone(&s.item)),
|
||||
block: block.map(|(pos, face)| (self.block_name(pos), pos, face)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{OFFHAND_SLOT, equipment_slot_name, living_flags, mob_freeze_meta};
|
||||
|
||||
@@ -2096,7 +2096,9 @@ impl Game<'_> {
|
||||
let stack = g
|
||||
.with_hand(hand, |g| g.held().cloned())
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
|
||||
g.trigger_any_block_use(p.pos, &stack);
|
||||
horizon_client::spans::time("use_advancements", || {
|
||||
g.trigger_any_block_use(p.pos, &stack);
|
||||
});
|
||||
}
|
||||
g.forced_hit = None;
|
||||
g.guest_use = None;
|
||||
|
||||
@@ -154,6 +154,20 @@ impl Levels {
|
||||
self.parked.get(&dim).map(|level| level.mobs.as_slice())
|
||||
}
|
||||
|
||||
/// A parked level's loose entities, for `horizon-report`: items, experience orbs,
|
||||
/// projectiles, minecarts and boats.
|
||||
pub(super) fn parked_entity_counts(&self, dim: Dim) -> Option<[usize; 5]> {
|
||||
self.parked.get(&dim).map(|l| {
|
||||
[
|
||||
l.dropped.as_slice().len(),
|
||||
l.orbs.as_slice().len(),
|
||||
l.projectiles.as_slice().len(),
|
||||
l.carts.as_slice().len(),
|
||||
l.boats.as_slice().len(),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// The levels that are not in place, by dimension.
|
||||
/// The forced chunks of every level not in place, released.
|
||||
pub(super) fn release_parked_forced_chunks(&mut self) {
|
||||
@@ -440,6 +454,13 @@ impl Game<'_> {
|
||||
let n = dims.len();
|
||||
let mut result = Ok(());
|
||||
for (k, dim) in dims.into_iter().enumerate() {
|
||||
// Each level's share of the tick (the first one also reads the network and runs the
|
||||
// tick's shared parts): `horizon-report`'s `level_*` spans.
|
||||
let _level_span = horizon_client::spans::guard(match dim {
|
||||
Dim::Overworld => "level_overworld",
|
||||
Dim::Nether => "level_the_nether",
|
||||
Dim::End => "level_the_end",
|
||||
});
|
||||
if let Err(e) = self.enter_level(None, dim) {
|
||||
result = Err(e);
|
||||
break;
|
||||
@@ -514,20 +535,24 @@ impl Game<'_> {
|
||||
/// `tick_server` as a server thread runs it. With `HORIZON_SPIKES=MS`, a
|
||||
/// tick longer than MS is logged with its thread CPU time and spans.
|
||||
pub(super) fn tick_server_timed(&mut self, paused: bool) -> Result<()> {
|
||||
self.tick_server_spans(paused, |_, _, _| {})
|
||||
self.tick_server_spans(paused, |_, _, _, _| {})
|
||||
}
|
||||
|
||||
/// `tick_server_timed`, handing the tick's wall time, thread CPU time
|
||||
/// and spans (milliseconds) to `record` while spans are on: a dedicated
|
||||
/// server's performance report turns them on to record its ticks.
|
||||
/// and spans (milliseconds, summed by name) to `record` while spans are on, with how many
|
||||
/// spans the tick itself recorded: a dedicated server's performance report turns them on to
|
||||
/// record its ticks.
|
||||
pub(super) fn tick_server_spans(
|
||||
&mut self,
|
||||
paused: bool,
|
||||
record: impl FnOnce(f32, Option<f32>, Vec<horizon_client::spans::Span>),
|
||||
record: impl FnOnce(f32, Option<f32>, Vec<horizon_client::spans::Span>, usize),
|
||||
) -> Result<()> {
|
||||
if self.options.spikes.is_none() && !horizon_client::spans::enabled() {
|
||||
return self.tick_server(paused);
|
||||
}
|
||||
// Spans recorded since the last tick ran between ticks (the chunk pump of the wait): they
|
||||
// stay in the tick's list, but only the tick's own count as recorded in it.
|
||||
let between = horizon_client::spans::take_raw();
|
||||
let (started, cpu) = (Instant::now(), horizon_client::spans::thread_cpu());
|
||||
let result = self.tick_server(paused);
|
||||
let wall = started.elapsed().as_secs_f32() * 1000.0;
|
||||
@@ -535,7 +560,13 @@ impl Game<'_> {
|
||||
.zip(cpu)
|
||||
.map(|(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
// Taken every tick: nothing else takes this thread's spans.
|
||||
let spans = horizon_client::spans::take();
|
||||
let raw = horizon_client::spans::take_raw();
|
||||
let in_tick = raw.len();
|
||||
let spans = if between.is_empty() {
|
||||
horizon_client::spans::merge(&raw)
|
||||
} else {
|
||||
horizon_client::spans::merge(&[between, raw].concat())
|
||||
};
|
||||
let notes = horizon_client::spans::take_notes();
|
||||
if let Some(threshold) = self.options.spikes
|
||||
&& wall > threshold
|
||||
@@ -548,7 +579,7 @@ impl Game<'_> {
|
||||
slowest_steps(¬es)
|
||||
);
|
||||
}
|
||||
record(wall, cpu, spans);
|
||||
record(wall, cpu, spans, in_tick);
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
@@ -911,11 +911,23 @@ impl Game<'_> {
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
self.native_register_commands(&mut commands);
|
||||
let (tree, levels, problems) =
|
||||
// Horizon's own moderation commands go in the same tree, so operators' clients complete them
|
||||
// instead of showing them as unknown; the game still runs them itself (`run_guest_command`).
|
||||
let extras = super::tempban::moderation_commands(
|
||||
host.hub.chat_filter_on(),
|
||||
self.server_lists.is_some(),
|
||||
);
|
||||
commands.extend(extras.iter().cloned());
|
||||
let (tree, mut levels, problems) =
|
||||
command_tree(&commands, |name| host.hub.vanilla_command(name));
|
||||
for p in problems {
|
||||
log::warn!("Horizon: a native mod's command was left out: {p}");
|
||||
}
|
||||
levels.retain(|name, _| {
|
||||
!extras
|
||||
.iter()
|
||||
.any(|c| c.usage.split(' ').next() == Some(name.as_str()))
|
||||
});
|
||||
host.hub.set_mod_commands(0, tree);
|
||||
host.native_commands = Some(levels);
|
||||
}
|
||||
|
||||
@@ -769,12 +769,18 @@ impl Game<'_> {
|
||||
/// and moving over blocks.
|
||||
pub(super) fn tick_sculk_entities(&mut self) {
|
||||
self.sculk.ticks = self.sculk.ticks.wrapping_add(1);
|
||||
// Its steps as spans for `horizon-report` (nothing is read while spans are off).
|
||||
let mut laps = horizon_client::spans::laps();
|
||||
self.sculk_prepare();
|
||||
laps.lap("sculk_prepare");
|
||||
self.load_warden_tracker();
|
||||
self.sculk.tracker.tick();
|
||||
self.tick_darkness_blend();
|
||||
laps.lap("sculk_wardens");
|
||||
self.sculk_step_on();
|
||||
laps.lap("sculk_step_on");
|
||||
self.tick_entity_events();
|
||||
laps.lap("sculk_entity_events");
|
||||
}
|
||||
/// The block entity phase for sculk: vibration tickers and catalysts, in
|
||||
/// the order the block entities were added (`Level.tickBlockEntities`).
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
//! itself. It is a `banned-players.json` entry with `expires` set, as the chat filter's automatic temporary
|
||||
//! ban makes (game/chat_filter.rs), so the login check, `/banlist` and vanilla's `/pardon` already treat it as
|
||||
//! they treat any ban, and it survives a restart. Operators of level 3 (`/ban`'s level) run it as a player; the
|
||||
//! console takes the same words without the slash. Vanilla has no such command, so it is not in the command
|
||||
//! tree (and has no completions), as the mute commands are not.
|
||||
//! console takes the same words without the slash. `/tempbanlist` lists the bans that end by themselves, with
|
||||
//! the time left. Vanilla has no such commands: they reach operators' command trees (completions included)
|
||||
//! as a native mod's commands do (`moderation_commands`), and the game runs them itself.
|
||||
use super::{Game, chat_filter::Reply, dedicated, lan::LanHost};
|
||||
use horizon_chat_filter::{duration_text, now_millis, parse_duration};
|
||||
use horizon_client::{mod_api::NativeCommand, text::Text};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_protocol::text::translatable;
|
||||
use horizon_server_files::user_lists::{BanEntry, BanList, NameAndId, format_date};
|
||||
@@ -13,10 +15,63 @@ use horizon_server_files::user_lists::{BanEntry, BanList, NameAndId, format_date
|
||||
/// The operator level `/tempban` needs, as `/ban`'s.
|
||||
const TEMPBAN_LEVEL: u8 = 3;
|
||||
|
||||
/// The commands of `tempban_command`.
|
||||
pub(super) const TEMPBAN_COMMANDS: [&str; 1] = ["tempban"];
|
||||
/// The commands of `tempban_command`: `/tempbanlist` lists the bans that end by themselves.
|
||||
pub(super) const TEMPBAN_COMMANDS: [&str; 2] = ["tempban", "tempbanlist"];
|
||||
|
||||
const USAGE: &str = "/tempban <player> <duration> [reason]";
|
||||
/// Horizon's moderation commands in operators' command trees (`command_tree`'s usages, for operators of
|
||||
/// `/ban`'s level): the mute commands when the server filters chat (`chat_filter`), `/tempban` and
|
||||
/// `/tempbanlist` on a dedicated server (`dedicated`), where the game runs them.
|
||||
pub(super) fn moderation_commands(chat_filter: bool, dedicated: bool) -> Vec<NativeCommand> {
|
||||
let mut usages = Vec::new();
|
||||
if chat_filter {
|
||||
usages.extend(super::chat_filter::MUTE_USAGES);
|
||||
}
|
||||
if dedicated {
|
||||
usages.extend([
|
||||
"tempban <player:player> <duration:word> [reason:text]",
|
||||
"tempbanlist",
|
||||
]);
|
||||
}
|
||||
usages
|
||||
.into_iter()
|
||||
.map(|usage| NativeCommand::op(usage, TEMPBAN_LEVEL))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `/tempbanlist`: the bans that end by themselves and haven't yet (`now`), the soonest to end first,
|
||||
/// with who banned, the time left and the reason.
|
||||
fn timed_bans(bans: &[&BanEntry<NameAndId>], now: i64) -> Vec<Reply> {
|
||||
let mut timed: Vec<(&BanEntry<NameAndId>, i64)> = bans
|
||||
.iter()
|
||||
.filter_map(|b| b.expires.filter(|&e| e >= now).map(|e| (*b, e)))
|
||||
.collect();
|
||||
if timed.is_empty() {
|
||||
return vec![Reply::translated("horizon.tempban.list.empty", &[])];
|
||||
}
|
||||
timed.sort_by_key(|&(b, e)| (e, b.user.name.clone()));
|
||||
let mut lines = vec![Reply::translated(
|
||||
"horizon.tempban.list.count",
|
||||
&[Text::literal(timed.len().to_string())],
|
||||
)];
|
||||
for (ban, expires) in timed {
|
||||
lines.push(
|
||||
Reply::translated(
|
||||
"horizon.tempban.list.entry",
|
||||
&[
|
||||
Text::literal(&ban.user.name),
|
||||
Text::literal(&ban.source),
|
||||
Reply::translation(
|
||||
"horizon.moderation.remaining",
|
||||
&[Reply::duration(expires - now)],
|
||||
),
|
||||
Text::literal(format_date(expires)),
|
||||
],
|
||||
)
|
||||
.with_reason(ban.reason.as_deref()),
|
||||
);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// What `/tempban` was asked: the player's name, how long, and the reason, or the line to answer.
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -28,12 +83,15 @@ struct Request<'a> {
|
||||
|
||||
/// `words` are the command and its arguments; the duration is `parse_duration`'s: a whole number and
|
||||
/// `s`, `m`, `h`, `d` or `w` (`30m`, `12h`, `7d`).
|
||||
fn parse<'a>(words: &[&'a str]) -> Result<Request<'a>, String> {
|
||||
fn parse<'a>(words: &[&'a str]) -> Result<Request<'a>, Reply> {
|
||||
let [_, name, time, reason @ ..] = words else {
|
||||
return Err(USAGE.into());
|
||||
return Err(Reply::translated("horizon.tempban.usage", &[]));
|
||||
};
|
||||
let Some(millis) = parse_duration(time) else {
|
||||
return Err(format!("{time} isn't a duration: 30m, 12h, 7d"));
|
||||
return Err(Reply::translated(
|
||||
"horizon.tempban.invalid_duration",
|
||||
&[Text::literal(*time)],
|
||||
));
|
||||
};
|
||||
Ok(Request {
|
||||
name,
|
||||
@@ -73,6 +131,24 @@ fn add_ban(
|
||||
Some(ban)
|
||||
}
|
||||
|
||||
fn success_reply(ban: &BanEntry<NameAndId>, millis: i64, expires: i64) -> Reply {
|
||||
let reason = ban.reason.as_ref().map_or_else(
|
||||
|| Text::translate("multiplayer.disconnect.banned.reason.default", &[]),
|
||||
Text::literal,
|
||||
);
|
||||
Reply::translated(
|
||||
"horizon.tempban.success",
|
||||
&[
|
||||
Text::translate(
|
||||
"commands.ban.success",
|
||||
&[Text::literal(&ban.user.name), reason],
|
||||
),
|
||||
Reply::duration(millis),
|
||||
Text::literal(format_date(expires)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A guest's `/tempban`: operators of level 3 (`/ban`'s); vanilla's unknown command for the others.
|
||||
pub(super) fn guest_tempban_command(&mut self, host: &mut LanHost, conn: u32, words: &[&str]) {
|
||||
@@ -107,9 +183,16 @@ impl Game<'_> {
|
||||
words: &[&str],
|
||||
by: &str,
|
||||
) -> Vec<Reply> {
|
||||
if words.first() == Some(&"tempbanlist") {
|
||||
let Some(lists) = self.server_lists.clone() else {
|
||||
return vec![Reply::translated("horizon.tempban.dedicated_required", &[])];
|
||||
};
|
||||
let lists = lists.lock().unwrap();
|
||||
return timed_bans(&lists.bans.entries(), now_millis());
|
||||
}
|
||||
match parse(words) {
|
||||
Ok(r) => vec![self.tempban(host, r.name, r.millis, r.reason, by)],
|
||||
Err(line) => vec![line.into()],
|
||||
Err(line) => vec![line],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +206,7 @@ impl Game<'_> {
|
||||
by: &str,
|
||||
) -> Reply {
|
||||
let Some(lists) = self.server_lists.clone() else {
|
||||
return "Temporary bans need a dedicated server".into();
|
||||
return Reply::translated("horizon.tempban.dedicated_required", &[]);
|
||||
};
|
||||
// A player here, else one the server has seen (the user cache).
|
||||
let user = host
|
||||
@@ -180,12 +263,7 @@ impl Game<'_> {
|
||||
"until": ban.expires.map(format_date),
|
||||
}),
|
||||
);
|
||||
format!(
|
||||
"Banned {} for {what}, until {}{why}",
|
||||
user.name,
|
||||
format_date(now + millis)
|
||||
)
|
||||
.into()
|
||||
success_reply(&ban, millis, now + millis)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,8 +301,15 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn bad_words_get_a_usage_or_the_bad_duration() {
|
||||
assert_eq!(parse(&["tempban"]), Err(USAGE.into()));
|
||||
assert_eq!(parse(&["tempban", "Steve"]), Err(USAGE.into()));
|
||||
for words in [&["tempban"][..], &["tempban", "Steve"][..]] {
|
||||
assert_eq!(
|
||||
parse(words).unwrap_err().text().component(),
|
||||
serde_json::json!({
|
||||
"translate": "horizon.tempban.usage",
|
||||
"fallback": "/tempban <player> <duration> [reason]"
|
||||
})
|
||||
);
|
||||
}
|
||||
for bad in [
|
||||
"soon",
|
||||
"0m",
|
||||
@@ -234,13 +319,85 @@ mod tests {
|
||||
"10",
|
||||
"99999999999999999999d",
|
||||
] {
|
||||
let reply = parse(&["tempban", "Steve", bad]).unwrap_err();
|
||||
let component = reply.text().component();
|
||||
assert_eq!(component["translate"], "horizon.tempban.invalid_duration");
|
||||
let argument = if bad == "10" {
|
||||
serde_json::json!(10)
|
||||
} else {
|
||||
serde_json::json!({"text": bad})
|
||||
};
|
||||
assert_eq!(component["with"], serde_json::json!([argument]), "{bad}");
|
||||
assert_eq!(
|
||||
parse(&["tempban", "Steve", bad]),
|
||||
Err(format!("{bad} isn't a duration: 30m, 12h, 7d")),
|
||||
"{bad}"
|
||||
horizon_client::scoreboard::tag_to_json(&reply.tag()),
|
||||
component
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_keeps_vanilla_reply_and_ban_data_nested_for_each_clients_language() {
|
||||
let now = 1_800_000_000_000;
|
||||
let millis = 12 * 3_600_000;
|
||||
for reason in [None, Some("griefing the farm".to_owned())] {
|
||||
let ban = entry(user(7, "Steve"), "Alex", now, millis, reason.clone());
|
||||
let reply = success_reply(&ban, millis, now + millis);
|
||||
let component = reply.text().component();
|
||||
assert_eq!(component["translate"], "horizon.tempban.success");
|
||||
assert_eq!(component["with"][0]["translate"], "commands.ban.success");
|
||||
assert_eq!(
|
||||
component["with"][0]["with"][0],
|
||||
serde_json::json!({"text": "Steve"})
|
||||
);
|
||||
let expected_reason = match &reason {
|
||||
Some(reason) => serde_json::json!({"text": reason}),
|
||||
None => {
|
||||
serde_json::json!({"translate": "multiplayer.disconnect.banned.reason.default"})
|
||||
}
|
||||
};
|
||||
assert_eq!(component["with"][0]["with"][1], expected_reason);
|
||||
assert!(
|
||||
component["with"][1]["translate"]
|
||||
.as_str()
|
||||
.is_some_and(|k| k.starts_with("horizon."))
|
||||
);
|
||||
assert_eq!(
|
||||
component["with"][2],
|
||||
serde_json::json!({"text": format_date(now + millis)})
|
||||
);
|
||||
assert_eq!(
|
||||
horizon_client::scoreboard::tag_to_json(&reply.tag()),
|
||||
component
|
||||
);
|
||||
assert_eq!(ban.reason, reason);
|
||||
assert_eq!(ban.expires, Some(now + millis));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn initialized_console_language_resolves_the_vanilla_message_and_duration() {
|
||||
use horizon_client::lang::{self, Language, ResourceSource};
|
||||
struct Vanilla;
|
||||
impl ResourceSource for Vanilla {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
(path == "assets/minecraft/lang/en_us.json").then(|| {
|
||||
br#"{
|
||||
"commands.ban.success": "ban-local %1$s / %2$s",
|
||||
"multiplayer.disconnect.banned.reason.default": "default-local"
|
||||
}"#
|
||||
.to_vec()
|
||||
})
|
||||
}
|
||||
}
|
||||
lang::scoped(Language::load(&[&Vanilla], "en_us"), || {
|
||||
let ban = entry(user(7, "Steve"), "Alex", 0, 12 * 3_600_000, None);
|
||||
let reply = success_reply(&ban, 12 * 3_600_000, 0);
|
||||
assert_eq!(
|
||||
reply.text().plain(),
|
||||
"ban-local Steve / default-local for 12 hours, until 1970-01-01 00:00:00 +0000"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ban_ends_after_its_duration_and_keeps_who_and_why() {
|
||||
let mut bans = list("ends");
|
||||
@@ -310,4 +467,104 @@ mod tests {
|
||||
assert!(after.get(&user(4, "Eve")).is_none());
|
||||
let _ = std::fs::remove_file(&bans.file);
|
||||
}
|
||||
|
||||
fn ban(
|
||||
id: u128,
|
||||
name: &str,
|
||||
by: &str,
|
||||
expires: Option<i64>,
|
||||
reason: Option<&str>,
|
||||
) -> BanEntry<NameAndId> {
|
||||
let mut b = BanEntry::new(
|
||||
user(id, name),
|
||||
Some(by.into()),
|
||||
expires,
|
||||
reason.map(Into::into),
|
||||
);
|
||||
b.created = 0;
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempbanlist_lists_bans_still_running_soonest_first_with_time_left() {
|
||||
let now = 1_800_000_000_000;
|
||||
let hour = 3_600_000;
|
||||
let bans = [
|
||||
ban(1, "Forever", "rdbtclient", None, Some("x-ray")),
|
||||
ban(2, "Ended", "luzW", Some(now - 1), None),
|
||||
ban(
|
||||
3,
|
||||
"Week",
|
||||
"luzW",
|
||||
Some(now + 7 * 24 * hour),
|
||||
Some("griefing"),
|
||||
),
|
||||
ban(4, "Hour", "Server", Some(now + hour), None),
|
||||
];
|
||||
let text = |bans: &[BanEntry<NameAndId>]| -> Vec<String> {
|
||||
timed_bans(&bans.iter().collect::<Vec<_>>(), now)
|
||||
.iter()
|
||||
.map(|l| horizon_protocol::text::plain(&l.tag(), &Default::default()))
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
text(&bans),
|
||||
[
|
||||
"There are 2 temporary ban(s):".to_owned(),
|
||||
format!(
|
||||
"Hour was banned by Server for another 60 minutes (until {})",
|
||||
format_date(now + hour)
|
||||
),
|
||||
format!(
|
||||
"Week was banned by luzW for another 7 days (until {}): griefing",
|
||||
format_date(now + 7 * 24 * hour)
|
||||
),
|
||||
]
|
||||
);
|
||||
// Only permanent or ended bans: nothing to list.
|
||||
assert_eq!(text(&bans[..2]), ["There are no temporary bans"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moderation_commands_reach_only_operators_trees_and_only_where_the_game_runs_them() {
|
||||
let names = |chat_filter, dedicated| {
|
||||
let (tree, levels, problems) = super::super::native_server::command_tree(
|
||||
&moderation_commands(chat_filter, dedicated),
|
||||
|_| false,
|
||||
);
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
for level in levels.values() {
|
||||
assert_eq!(*level, 3);
|
||||
}
|
||||
if let Some(children) = tree["children"].as_object() {
|
||||
for (name, node) in children {
|
||||
assert_eq!(
|
||||
node["permissions"],
|
||||
serde_json::json!({"type": "require", "permission": {"level": "admins"}}),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut names: Vec<String> = levels.into_keys().collect();
|
||||
names.sort();
|
||||
names
|
||||
};
|
||||
assert_eq!(
|
||||
names(true, true),
|
||||
[
|
||||
"mute",
|
||||
"mutelist",
|
||||
"tempban",
|
||||
"tempbanlist",
|
||||
"tempmute",
|
||||
"unmute"
|
||||
]
|
||||
);
|
||||
assert_eq!(names(false, true), ["tempban", "tempbanlist"]);
|
||||
assert_eq!(
|
||||
names(true, false),
|
||||
["mute", "mutelist", "tempmute", "unmute"]
|
||||
);
|
||||
assert!(names(false, false).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! What each kind of mob costs the server tick, for `horizon-report` (dedicated/report.rs): while
|
||||
//! a report records, one tick in [`SAMPLE_EVERY`] times every mob's own tick and adds it up by
|
||||
//! level and mob type. Off, a mob costs one thread-local read; sampled, two clock reads and a
|
||||
//! map update. A mob's riders tick inside its own tick, so they count for it.
|
||||
use horizon_client::{fast_hash::FxHashMap, mobs::MobType};
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// One server tick in this many is sampled while a report records.
|
||||
pub(super) const SAMPLE_EVERY: usize = 4;
|
||||
|
||||
/// The time and the ticks of one mob type in one level.
|
||||
struct Cost {
|
||||
kind: String,
|
||||
nanos: u64,
|
||||
mob_ticks: u64,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SAMPLING: Cell<bool> = const { Cell::new(false) };
|
||||
static COSTS: RefCell<FxHashMap<(&'static str, usize), Cost>> = RefCell::default();
|
||||
}
|
||||
|
||||
/// Sample (or stop sampling) the mob ticks this thread runs next.
|
||||
pub(super) fn set_sampling(on: bool) {
|
||||
SAMPLING.with(|s| s.set(on));
|
||||
}
|
||||
|
||||
/// When this tick is sampled, the start of a mob's tick.
|
||||
#[inline]
|
||||
pub(super) fn start() -> Option<Instant> {
|
||||
SAMPLING.with(Cell::get).then(Instant::now)
|
||||
}
|
||||
|
||||
/// A sampled mob of type `kind` in level `dim` ticked since `started`.
|
||||
#[inline]
|
||||
pub(super) fn add(dim: &'static str, kind: &MobType, started: Instant) {
|
||||
add_kind(dim, std::ptr::from_ref(kind) as usize, &kind.id, started);
|
||||
}
|
||||
|
||||
/// [`add`] for the type at address `key` named `id`.
|
||||
fn add_kind(dim: &'static str, key: usize, id: &str, started: Instant) {
|
||||
let nanos = started.elapsed().as_nanos() as u64;
|
||||
COSTS.with(|c| {
|
||||
let mut costs = c.borrow_mut();
|
||||
let cost = costs.entry((dim, key)).or_insert_with(|| Cost {
|
||||
kind: id.to_owned(),
|
||||
nanos: 0,
|
||||
mob_ticks: 0,
|
||||
});
|
||||
cost.nanos += nanos;
|
||||
cost.mob_ticks += 1;
|
||||
});
|
||||
}
|
||||
|
||||
/// One level's mob type: its sampled time and mob ticks.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct MobCost {
|
||||
pub dimension: &'static str,
|
||||
pub kind: String,
|
||||
pub nanos: u64,
|
||||
pub mob_ticks: u64,
|
||||
}
|
||||
|
||||
/// The costs sampled since the last call, costliest first; clears them.
|
||||
pub(super) fn take() -> Vec<MobCost> {
|
||||
let costs = COSTS.with(|c| std::mem::take(&mut *c.borrow_mut()));
|
||||
let mut out: Vec<MobCost> = costs
|
||||
.into_iter()
|
||||
.map(|((dimension, _), c)| MobCost {
|
||||
dimension,
|
||||
kind: c.kind,
|
||||
nanos: c.nanos,
|
||||
mob_ticks: c.mob_ticks,
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.nanos.cmp(&a.nanos).then_with(|| a.kind.cmp(&b.kind)));
|
||||
out
|
||||
}
|
||||
|
||||
/// What sampling one mob costs on this machine, in nanoseconds: the report's own overhead.
|
||||
pub(super) fn sample_cost_nanos() -> f64 {
|
||||
const N: u32 = 20_000;
|
||||
let was = SAMPLING.with(Cell::get);
|
||||
let kept = COSTS.with(|c| std::mem::take(&mut *c.borrow_mut()));
|
||||
set_sampling(true);
|
||||
let timer = Instant::now();
|
||||
for _ in 0..N {
|
||||
if let Some(started) = start() {
|
||||
add_kind("calibration", 1, "calibration", started);
|
||||
}
|
||||
}
|
||||
let per = timer.elapsed().as_nanos() as f64 / f64::from(N);
|
||||
set_sampling(was);
|
||||
COSTS.with(|c| *c.borrow_mut() = kept);
|
||||
per
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A mob type at a made-up address.
|
||||
fn add(dim: &'static str, (key, id): (usize, &str), started: Instant) {
|
||||
add_kind(dim, key, id, started);
|
||||
}
|
||||
|
||||
/// Mobs add up by level and type, only on sampled ticks; taking them clears them.
|
||||
#[test]
|
||||
fn costs_add_up_by_level_and_type_while_sampling() {
|
||||
let (zombie, cow) = ((1, "minecraft:zombie"), (2, "minecraft:cow"));
|
||||
set_sampling(false);
|
||||
assert!(start().is_none(), "nothing is timed while off");
|
||||
set_sampling(true);
|
||||
for _ in 0..3 {
|
||||
let t = start().expect("sampled");
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
add("minecraft:overworld", zombie, t);
|
||||
}
|
||||
add("minecraft:overworld", cow, Instant::now());
|
||||
add("minecraft:the_nether", zombie, Instant::now());
|
||||
set_sampling(false);
|
||||
let costs = take();
|
||||
assert_eq!(costs.len(), 3, "{costs:?}");
|
||||
assert_eq!(costs[0].kind, "minecraft:zombie");
|
||||
assert_eq!(costs[0].dimension, "minecraft:overworld");
|
||||
assert_eq!(costs[0].mob_ticks, 3);
|
||||
assert!(costs[0].nanos >= 3_000_000, "{}", costs[0].nanos);
|
||||
let nether = costs
|
||||
.iter()
|
||||
.find(|c| c.dimension == "minecraft:the_nether")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(nether.kind.as_str(), nether.mob_ticks),
|
||||
("minecraft:zombie", 1)
|
||||
);
|
||||
assert!(take().is_empty());
|
||||
}
|
||||
|
||||
/// Calibrating leaves what was sampled, and the switch, as they were.
|
||||
#[test]
|
||||
fn calibration_keeps_the_samples() {
|
||||
set_sampling(true);
|
||||
add("minecraft:overworld", (3, "minecraft:pig"), Instant::now());
|
||||
let per = sample_cost_nanos();
|
||||
assert!(per > 0.0 && per < 100_000.0, "{per}");
|
||||
assert!(start().is_some(), "still sampling");
|
||||
set_sampling(false);
|
||||
let costs = take();
|
||||
assert_eq!(costs.len(), 1);
|
||||
assert_eq!(costs[0].kind, "minecraft:pig");
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use super::{
|
||||
};
|
||||
use horizon_protocol::{
|
||||
PROTOCOL_VERSION,
|
||||
connection::{ConnWriter, Connection},
|
||||
connection::{ConnWriter, Connection, Counters},
|
||||
crypto::{offline_uuid, server_hash, uuid_string},
|
||||
packets::{
|
||||
Empty,
|
||||
@@ -243,6 +243,9 @@ pub struct PlayerSender {
|
||||
pub pending: Arc<AtomicUsize>,
|
||||
held: Arc<Mutex<Vec<Vec<u8>>>>,
|
||||
to: To,
|
||||
/// The connection's bytes on the wire (after compression and encryption), counted by its
|
||||
/// network thread: `horizon-report`'s network use per player. None for a test's channel.
|
||||
wire: Option<Arc<Counters>>,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
enum To {
|
||||
@@ -258,8 +261,19 @@ impl PlayerSender {
|
||||
pending,
|
||||
held: Arc::default(),
|
||||
to: To::Channel(tx),
|
||||
wire: None,
|
||||
}
|
||||
}
|
||||
/// The bytes received from and sent to the client on the wire so far, where the
|
||||
/// connection counts them.
|
||||
pub fn wire_bytes(&self) -> Option<(u64, u64)> {
|
||||
self.wire.as_ref().map(|c| {
|
||||
(
|
||||
c.bytes_in.load(Ordering::Relaxed).max(0) as u64,
|
||||
c.bytes_out.load(Ordering::Relaxed).max(0) as u64,
|
||||
)
|
||||
})
|
||||
}
|
||||
/// Whether the client has answered no keep-alive for too long.
|
||||
pub fn timed_out(&self) -> bool {
|
||||
matches!(&self.to, To::Pool(_, watch) if watch.timed_out())
|
||||
@@ -1248,6 +1262,7 @@ fn play(
|
||||
pending,
|
||||
held: Arc::default(),
|
||||
to: To::Pool(outbox.clone(), watch.clone()),
|
||||
wire: Some(c.reader.counters.clone()),
|
||||
};
|
||||
let required_pack = shared
|
||||
.config
|
||||
|
||||
@@ -1825,10 +1825,12 @@ impl Hub {
|
||||
if ctx.world.light_log.is_none() {
|
||||
ctx.world.light_log = Some(Default::default());
|
||||
}
|
||||
self.sync_world(ctx);
|
||||
crate::spans::time("lan_sync_world", || self.sync_world(ctx));
|
||||
crate::spans::time("lan_chunks", || self.update_chunks(ctx));
|
||||
self.broadcast_light_changes(ctx.world);
|
||||
self.broadcast_block_changes(ctx.world);
|
||||
crate::spans::time("lan_block_changes", || {
|
||||
self.broadcast_light_changes(ctx.world);
|
||||
self.broadcast_block_changes(ctx.world);
|
||||
});
|
||||
// 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.
|
||||
@@ -1838,15 +1840,17 @@ impl Hub {
|
||||
g.send(&C::BlockChangedAck(play::BlockChangedAck { sequence }));
|
||||
}
|
||||
}
|
||||
self.sync_containers(ctx.world);
|
||||
crate::spans::time("lan_containers", || self.sync_containers(ctx.world));
|
||||
self.sync_hidden();
|
||||
self.track_entities(ctx);
|
||||
for i in 0..self.guests.len() {
|
||||
if self.here(&self.guests[i]) {
|
||||
self.sync_player_state(i);
|
||||
self.sync_effects(i);
|
||||
crate::spans::time("lan_track_entities", || self.track_entities(ctx));
|
||||
crate::spans::time("lan_player_state", || {
|
||||
for i in 0..self.guests.len() {
|
||||
if self.here(&self.guests[i]) {
|
||||
self.sync_player_state(i);
|
||||
self.sync_effects(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
events
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct SavedOldWorld {
|
||||
data: Arc<GeneratorData>,
|
||||
/// The old chunks of each region file (`SaveDir::old_chunk_bits`).
|
||||
regions: Mutex<HashMap<(i32, i32), Arc<OnceLock<[u64; 16]>>>>,
|
||||
/// Chunks asked about one at a time in regions not scanned ([`OldWorld::is_old_chunk`]):
|
||||
/// per region, the chunks read and the old ones among them.
|
||||
single: Mutex<HashMap<(i32, i32), ([u64; 16], [u64; 16])>>,
|
||||
/// Each old chunk's blending data, its border columns calculated.
|
||||
blending: Mutex<HashMap<(i32, i32), Option<Arc<BlendingData>>>>,
|
||||
kept_biomes: Mutex<HashMap<(i32, i32), Option<Arc<BiomeChunk<BiomeId>>>>>,
|
||||
@@ -43,6 +46,7 @@ impl SavedOldWorld {
|
||||
biomes,
|
||||
data,
|
||||
regions: Default::default(),
|
||||
single: Default::default(),
|
||||
blending: Default::default(),
|
||||
kept_biomes: Default::default(),
|
||||
}
|
||||
@@ -152,6 +156,28 @@ impl OldWorld for SavedOldWorld {
|
||||
}
|
||||
false
|
||||
}
|
||||
fn is_old_chunk(&self, x: i32, z: i32) -> bool {
|
||||
let key = (x >> 5, z >> 5);
|
||||
let index = ((x & 31) + (z & 31) * 32) as usize;
|
||||
let (word, bit) = (index / 64, 1u64 << (index % 64));
|
||||
let scanned = (self.regions.lock().unwrap().get(&key)).and_then(|cell| cell.get().copied());
|
||||
if let Some(bits) = scanned {
|
||||
return bits[word] & bit != 0;
|
||||
}
|
||||
if let Some((read, old)) = self.single.lock().unwrap().get(&key)
|
||||
&& read[word] & bit != 0
|
||||
{
|
||||
return old[word] & bit != 0;
|
||||
}
|
||||
let old = self.save.is_old_chunk(x, z);
|
||||
let mut single = self.single.lock().unwrap();
|
||||
let (read, olds) = single.entry(key).or_default();
|
||||
read[word] |= bit;
|
||||
if old {
|
||||
olds[word] |= bit;
|
||||
}
|
||||
old
|
||||
}
|
||||
fn saved_starts(&self, x: i32, z: i32) -> Vec<horizon_world::worldgen::blending::SavedStart> {
|
||||
self.save
|
||||
.raw_chunk(x, z)
|
||||
|
||||
@@ -2562,17 +2562,19 @@ impl SaveDir {
|
||||
let Some(root) = self.read_record_raw("region", pos.x, pos.z) else {
|
||||
continue;
|
||||
};
|
||||
let old = match root.tag.get("DataVersion") {
|
||||
Some(Tag::Int(version)) => *version < 4997,
|
||||
_ => true,
|
||||
} || root.tag.get("blending_data").is_some();
|
||||
if old {
|
||||
if record_is_old(&root) {
|
||||
let index = ((pos.x & 31) + (pos.z & 31) * 32) as usize;
|
||||
bits[index / 64] |= 1 << (index % 64);
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
/// Whether the saved chunk at `x, z` is old ([`Self::old_chunk_bits`]'s test), from its
|
||||
/// record alone: false for a chunk not saved.
|
||||
pub fn is_old_chunk(&self, x: i32, z: i32) -> bool {
|
||||
self.read_record_raw("region", x, z)
|
||||
.is_some_and(|root| record_is_old(&root))
|
||||
}
|
||||
/// A saved chunk brought to the current version, for the readers of its
|
||||
/// fields that `load` does not decode (`blending_data`).
|
||||
pub fn raw_chunk(&self, x: i32, z: i32) -> Option<NamedTag> {
|
||||
@@ -5064,6 +5066,15 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `IOWorker.createOldDataForRegion`'s test of one record: saved before DataVersion 4997, or
|
||||
/// with `blending_data`.
|
||||
fn record_is_old(root: &NamedTag) -> bool {
|
||||
let before_blending = match root.tag.get("DataVersion") {
|
||||
Some(Tag::Int(version)) => *version < 4997,
|
||||
_ => true,
|
||||
};
|
||||
before_blending || root.tag.get("blending_data").is_some()
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::mobs::spawner::SpawnDataExt;
|
||||
@@ -7440,6 +7451,30 @@ mod tests {
|
||||
let upgraded = nether.raw_chunk(0, 0).expect("the chunk reads");
|
||||
assert!(upgraded.tag.get("blending_data").is_none());
|
||||
assert_ne!(nether.old_chunk_bits(0, 0)[0] & 1, 0);
|
||||
// One chunk's record tells the same, without the region's other chunks; a chunk this
|
||||
// version saved, and one not saved, are not old.
|
||||
let current = Record::Tag(Arc::new(NamedTag {
|
||||
name: "".into(),
|
||||
tag: Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
("xPos", Tag::Int(1)),
|
||||
("zPos", Tag::Int(0)),
|
||||
("yPos", Tag::Int(0)),
|
||||
("Status", string("minecraft:full")),
|
||||
("sections", Tag::List(Vec::new())),
|
||||
]),
|
||||
}));
|
||||
super::write_region_records(
|
||||
&nether.root,
|
||||
&nether.regions,
|
||||
"region",
|
||||
&[((1, 0), ¤t)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(nether.is_old_chunk(0, 0));
|
||||
assert!(!nether.is_old_chunk(1, 0));
|
||||
assert!(!nether.is_old_chunk(2, 0));
|
||||
assert_eq!(nether.old_chunk_bits(0, 0)[0] & 0b110, 0);
|
||||
// A POI record of before 1.14.4 (no DataVersion, read as 1945): its
|
||||
// sections at the root, which 1946 moved under `Sections`.
|
||||
let overworld = SaveDir::new(
|
||||
|
||||
@@ -404,6 +404,13 @@ pub trait OldWorld: Send + Sync {
|
||||
/// `WorldGenRegion.isOldChunkAround`: a chunk within `range` of `center`
|
||||
/// (a square) is old, as far as the storage can tell without reading it.
|
||||
fn is_old_chunk_around(&self, center: ChunkPos, range: i32) -> bool;
|
||||
/// Whether the chunk at `x, z` is old, as `StructureCheck.tryLoadFromStorage` finds out:
|
||||
/// from that chunk's record alone. A structure search asks this of one chunk per spacing
|
||||
/// cell across thousands of cells; scanning each region it touched, as the blender does,
|
||||
/// read every chunk an explored world saved there (#8669).
|
||||
fn is_old_chunk(&self, x: i32, z: i32) -> bool {
|
||||
self.is_old_chunk_around(ChunkPos::new(x, z), 0)
|
||||
}
|
||||
/// The blending data of the old chunk at `x, z`, its border columns
|
||||
/// calculated, or None for a chunk that is not old.
|
||||
fn blending(&self, x: i32, z: i32) -> Option<Arc<BlendingData>>;
|
||||
|
||||
@@ -2014,7 +2014,7 @@ where
|
||||
self.pipeline
|
||||
.old_world
|
||||
.get()
|
||||
.is_some_and(|world| world.is_old_chunk_around(chunk, 0))
|
||||
.is_some_and(|world| world.is_old_chunk(chunk.x, chunk.z))
|
||||
}
|
||||
fn saved_starts(&self, chunk: ChunkPos) -> Vec<super::blending::SavedStart> {
|
||||
self.pipeline
|
||||
@@ -2455,6 +2455,16 @@ trait SeededPipeline: Send + Sync {
|
||||
) -> Result<(), String>;
|
||||
fn forget_structure_starts(&self);
|
||||
fn ring_positions(&self, worker: &mut WorkerState, set: &str) -> Option<Vec<ChunkPos>>;
|
||||
/// The structures whose random-spread set may start in `chunk`, each with whether a
|
||||
/// search's start check lets it start there.
|
||||
#[cfg(test)]
|
||||
fn may_start_answers(
|
||||
&self,
|
||||
_worker: &mut WorkerState,
|
||||
_chunk: ChunkPos,
|
||||
) -> Vec<(String, bool)> {
|
||||
Vec::new()
|
||||
}
|
||||
fn spawn_position(&self) -> Result<[i32; 2], GeneratorError>;
|
||||
fn region_random(&self, center: ChunkPos) -> Box<dyn super::structure::legacy::PieceRandom>;
|
||||
fn locate(
|
||||
@@ -2624,6 +2634,20 @@ where
|
||||
structures.forget_starts();
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn may_start_answers(&self, worker: &mut WorkerState, chunk: ChunkPos) -> Vec<(String, bool)> {
|
||||
match &self.structures {
|
||||
Some(structures) => structures.may_start_answers(
|
||||
&mut StructureQueries {
|
||||
pipeline: self,
|
||||
worker,
|
||||
valid: BTreeSet::new(),
|
||||
},
|
||||
chunk,
|
||||
),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
fn ring_positions(&self, worker: &mut WorkerState, set: &str) -> Option<Vec<ChunkPos>> {
|
||||
self.structures.as_ref()?.ring_positions(
|
||||
&mut StructureQueries {
|
||||
@@ -3729,6 +3753,10 @@ impl GeneratorWorker {
|
||||
pub fn forget_structure_starts(&mut self) {
|
||||
self.pipeline.forget_structure_starts();
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn may_start_answers(&mut self, chunk: ChunkPos) -> Vec<(String, bool)> {
|
||||
self.pipeline.may_start_answers(&mut self.state, chunk)
|
||||
}
|
||||
pub fn stage_timings(&self) -> StageTimings {
|
||||
self.state.timings
|
||||
}
|
||||
@@ -4282,6 +4310,44 @@ mod tests {
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
/// A map's structure search skips a chunk's starts when no wanted structure's start
|
||||
/// piece and biome fit there (#8669). Over the candidate chunks of every random-spread
|
||||
/// set around the camp TiesB2007's map went looking from, a skipped structure is never
|
||||
/// one the chunk's full starts hold, and some are skipped.
|
||||
#[test]
|
||||
fn may_start_never_skips_a_start_the_chunk_holds() {
|
||||
let Some(data) = prepared_data() else {
|
||||
return;
|
||||
};
|
||||
let generator = OverworldGenerator::new(data, -3292718948513116954).expect("generator");
|
||||
let mut worker = generator.worker();
|
||||
let (mut held, mut skipped) = (0, 0);
|
||||
for x in 212..332 {
|
||||
for z in -481..-361 {
|
||||
let chunk = ChunkPos::new(x, z);
|
||||
let checks = worker.may_start_answers(chunk);
|
||||
if checks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let starts: Vec<String> = (worker.structure_starts(chunk).iter())
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
for (name, may_start) in checks {
|
||||
if starts.contains(&name) {
|
||||
assert!(
|
||||
may_start,
|
||||
"{name} starts in {chunk:?}, but its check skips it"
|
||||
);
|
||||
held += 1;
|
||||
} else if !may_start {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(held > 0 && skipped > 0, "held {held}, skipped {skipped}");
|
||||
}
|
||||
|
||||
/// Generators of one seed and dimension share their ring positions;
|
||||
/// another seed or generator gets its own, and old entries go.
|
||||
#[test]
|
||||
|
||||
@@ -8,7 +8,7 @@ use super::{
|
||||
jigsaw::PoolPiece,
|
||||
start::{
|
||||
Piece, Rings, StartContext, StructureRegistry, StructureStart, beardifier,
|
||||
create_starts, try_generate,
|
||||
create_starts, may_start, try_generate,
|
||||
},
|
||||
stronghold::RingSearch,
|
||||
},
|
||||
@@ -379,6 +379,39 @@ impl StructureState {
|
||||
.any(|(_, search)| search.may_land_near(chunk.x, chunk.z, self.zone_reach));
|
||||
if near { self.rings(context) } else { &NONE }
|
||||
}
|
||||
/// The structures of the random-spread sets whose start chunk is `chunk`, each with
|
||||
/// [`may_start`]'s answer, for tests.
|
||||
#[cfg(test)]
|
||||
pub(super) fn may_start_answers(
|
||||
&self,
|
||||
context: &mut impl super::structure::start::StartContext,
|
||||
chunk: ChunkPos,
|
||||
) -> Vec<(String, bool)> {
|
||||
let seed = context.seed();
|
||||
let rings = self.rings_for(context, chunk).clone();
|
||||
let mut checks = Vec::new();
|
||||
for &si in &self.possible_sets {
|
||||
let set = &self.registry.sets[si];
|
||||
if set.spacing().is_none()
|
||||
|| StructureRegistry::potential_chunk(set, seed, chunk.x, chunk.z) != Some(chunk)
|
||||
|| !(self.registry).is_structure_chunk(set, seed, chunk.x, chunk.z, &rings)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for &(s, _) in &set.structures {
|
||||
let may = may_start(
|
||||
&self.registry,
|
||||
&self.resources,
|
||||
&self.bindings,
|
||||
context,
|
||||
s,
|
||||
chunk,
|
||||
);
|
||||
checks.push((self.registry.structures[s].name.clone(), may));
|
||||
}
|
||||
}
|
||||
checks
|
||||
}
|
||||
/// `StructureRegistry::needs_dimension_origin`: whether any set is placed
|
||||
/// by the dimension's origin chunk alone.
|
||||
pub(super) fn needs_dimension_origin(&self) -> bool {
|
||||
@@ -602,9 +635,9 @@ impl StructureState {
|
||||
/// its locate position (the start chunk's corner at y 0). `skip` says a
|
||||
/// start (structure name, its chunk) can't be referenced again
|
||||
/// (`StructureStart.canBeReferenced`): the search goes on past it.
|
||||
pub(super) fn locate(
|
||||
pub(super) fn locate<C: super::structure::start::StartContext>(
|
||||
&self,
|
||||
context: &mut impl super::structure::start::StartContext,
|
||||
context: &mut C,
|
||||
names: &[String],
|
||||
from: [i32; 3],
|
||||
radius: i32,
|
||||
@@ -618,7 +651,30 @@ impl StructureState {
|
||||
dx * dx + dz * dz
|
||||
};
|
||||
let mut best: Option<(f64, String, [i32; 3])> = None;
|
||||
let found_at = |this: &Self, context: &mut _, chunk: ChunkPos| -> Option<String> {
|
||||
let wanted_structures: Vec<usize> = (0..self.registry.structures.len())
|
||||
.filter(|&s| wanted(s))
|
||||
.collect();
|
||||
let found_at = |this: &Self, context: &mut C, chunk: ChunkPos| -> Option<String> {
|
||||
// StructureCheck.checkStart: a chunk with no starts built or saved yet is first
|
||||
// asked whether a wanted structure could start there (canCreateStructure, a
|
||||
// jigsaw's start piece and its biome), and only then are all its starts built,
|
||||
// layouts included. A map's search for a camp of a rarer biome built thousands of
|
||||
// chunks' starts on the server thread, seconds of it (#8669).
|
||||
if this.starts.get((chunk.x, chunk.z)).is_none()
|
||||
&& !context.is_old_chunk(chunk)
|
||||
&& !wanted_structures.iter().any(|&s| {
|
||||
may_start(
|
||||
&this.registry,
|
||||
&this.resources,
|
||||
&this.bindings,
|
||||
context,
|
||||
s,
|
||||
chunk,
|
||||
)
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
this.starts_at(context, chunk)
|
||||
.iter()
|
||||
.find(|s| wanted(s.structure) && !skip(&s.name, chunk))
|
||||
|
||||
@@ -1075,6 +1075,37 @@ pub fn assemble_drawing(
|
||||
random: &mut impl RandomSource,
|
||||
position: [i32; 3],
|
||||
) -> Option<([i32; 3], Vec<PoolPiece>)> {
|
||||
let start = place_start(
|
||||
draws, config, context, pools, resources, bindings, random, position,
|
||||
)?;
|
||||
Some(lay_out_children(
|
||||
draws, start, config, context, pools, resources, bindings, random,
|
||||
))
|
||||
}
|
||||
|
||||
/// A jigsaw's start piece in place ([`place_start`]), its children not laid out yet: vanilla's
|
||||
/// `Structure.GenerationStub`, whose position the structure's biome is checked at before
|
||||
/// `JigsawPlacement.addPieces` lays the rest out.
|
||||
pub struct PlacedStart {
|
||||
/// The start position (`[centre x, start y, centre z]`).
|
||||
pub position: [i32; 3],
|
||||
center: PoolPiece,
|
||||
}
|
||||
|
||||
/// The first half of [`assemble_drawing`]: the start piece drawn, rotated, projected to the
|
||||
/// heightmap and checked against the height limits. None when the start is invalid. The
|
||||
/// draws it makes come first in `random`, as in `assemble_drawing`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn place_start(
|
||||
draws: Draws,
|
||||
config: &JigsawConfig<'_>,
|
||||
context: &mut impl JigsawContext,
|
||||
pools: &Pools,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
random: &mut impl RandomSource,
|
||||
position: [i32; 3],
|
||||
) -> Option<PlacedStart> {
|
||||
let alias = |name: &str| {
|
||||
config
|
||||
.aliases
|
||||
@@ -1144,6 +1175,29 @@ pub fn assemble_drawing(
|
||||
return None;
|
||||
}
|
||||
let cy = bottom + local[1];
|
||||
Some(PlacedStart {
|
||||
position: [cx, cy, cz],
|
||||
center,
|
||||
})
|
||||
}
|
||||
|
||||
/// The second half of [`assemble_drawing`]: the children of a [`place_start`]ed start laid out
|
||||
/// to `config.max_depth`, with the same `random`. Returns the start position and every piece.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn lay_out_children(
|
||||
draws: Draws,
|
||||
start: PlacedStart,
|
||||
config: &JigsawConfig<'_>,
|
||||
context: &mut impl JigsawContext,
|
||||
pools: &Pools,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
random: &mut impl RandomSource,
|
||||
) -> ([i32; 3], Vec<PoolPiece>) {
|
||||
let PlacedStart {
|
||||
position: [cx, cy, cz],
|
||||
center,
|
||||
} = start;
|
||||
let mut pieces = vec![center];
|
||||
if config.max_depth > 0 {
|
||||
let [h, v] = config.max_distance;
|
||||
@@ -1164,7 +1218,7 @@ pub fn assemble_drawing(
|
||||
container,
|
||||
);
|
||||
}
|
||||
Some(([cx, cy, cz], pieces))
|
||||
([cx, cy, cz], pieces)
|
||||
}
|
||||
|
||||
/// The inner JigsawPlacement.addPieces: lays out the children of
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
use super::{
|
||||
BoundingBox, StructureResources,
|
||||
jigsaw::{
|
||||
JigsawConfig, JigsawContext, PlaceFeature, PoolPiece, Pools, Projection, assemble_drawing,
|
||||
JigsawConfig, JigsawContext, PlaceFeature, PoolPiece, Pools, Projection, lay_out_children,
|
||||
place_start,
|
||||
},
|
||||
legacy::{LegacyPiece, PieceContext},
|
||||
};
|
||||
@@ -1018,7 +1019,53 @@ pub(crate) fn try_generate(
|
||||
chunk: ChunkPos,
|
||||
any_biome: bool,
|
||||
) -> Option<StructureStart> {
|
||||
match generate(
|
||||
registry, resources, bindings, context, structure, chunk, any_biome, false,
|
||||
)? {
|
||||
Generated::Start(start) => Some(start),
|
||||
Generated::Stub => None,
|
||||
}
|
||||
}
|
||||
/// `StructureCheck.canCreateStructure`: whether `structure` may start in `chunk`, by
|
||||
/// `Structure.findValidGenerationPoint` alone. A jigsaw's start piece is placed and its biome
|
||||
/// checked; its children are not laid out. Other structures answer true: their starts are
|
||||
/// cheap to build. False only where [`try_generate`] can't start it, so a search may skip
|
||||
/// the chunk's full starts then (#8669).
|
||||
pub(crate) fn may_start(
|
||||
registry: &StructureRegistry,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
context: &mut impl StartContext,
|
||||
structure: usize,
|
||||
chunk: ChunkPos,
|
||||
) -> bool {
|
||||
generate(
|
||||
registry, resources, bindings, context, structure, chunk, false, true,
|
||||
)
|
||||
.is_some()
|
||||
}
|
||||
/// What [`generate`] made: the start, or with `stub_only` only that it may start.
|
||||
enum Generated {
|
||||
Start(StructureStart),
|
||||
Stub,
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn generate(
|
||||
registry: &StructureRegistry,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
context: &mut impl StartContext,
|
||||
structure: usize,
|
||||
chunk: ChunkPos,
|
||||
any_biome: bool,
|
||||
stub_only: bool,
|
||||
) -> Option<Generated> {
|
||||
let def = ®istry.structures[structure];
|
||||
let vanilla_jigsaw =
|
||||
matches!(&def.kind, StructureKind::Jigsaw(jigsaw) if jigsaw.piece_limited.is_none());
|
||||
if stub_only && !vanilla_jigsaw {
|
||||
return Some(Generated::Stub);
|
||||
}
|
||||
context.set_valid_biomes((!any_biome).then_some(&def.biomes));
|
||||
let seed = context.seed();
|
||||
let mut random = WorldgenRandom::new(LegacyRandom::new(0));
|
||||
@@ -1061,16 +1108,38 @@ pub(crate) fn try_generate(
|
||||
chunk,
|
||||
start,
|
||||
)?,
|
||||
None => assemble_drawing(
|
||||
jigsaw.draws,
|
||||
&config,
|
||||
context,
|
||||
®istry.pools,
|
||||
resources,
|
||||
bindings,
|
||||
&mut random,
|
||||
start,
|
||||
)?,
|
||||
None => {
|
||||
let placed = place_start(
|
||||
jigsaw.draws,
|
||||
&config,
|
||||
context,
|
||||
®istry.pools,
|
||||
resources,
|
||||
bindings,
|
||||
&mut random,
|
||||
start,
|
||||
)?;
|
||||
// Structure.findValidGenerationPoint checks the stub's biome before its
|
||||
// GenerationStub lays the pieces out: a start in another biome costs its
|
||||
// start piece, not its whole layout (an ancient city's, a camp's).
|
||||
let quart = placed.position.map(|v| v >> 2);
|
||||
if !any_biome && !def.biomes.contains(&context.noise_biome(quart)) {
|
||||
return None;
|
||||
}
|
||||
if stub_only {
|
||||
return Some(Generated::Stub);
|
||||
}
|
||||
lay_out_children(
|
||||
jigsaw.draws,
|
||||
placed,
|
||||
&config,
|
||||
context,
|
||||
®istry.pools,
|
||||
resources,
|
||||
bindings,
|
||||
&mut random,
|
||||
)
|
||||
}
|
||||
};
|
||||
// Vanilla's JigsawPlacement keeps no piece at size 0; the
|
||||
// piece-limited assembler keeps the start piece.
|
||||
@@ -1331,7 +1400,9 @@ pub(crate) fn try_generate(
|
||||
if (!any_biome && !def.biomes.contains(&context.noise_biome(quart))) || pieces.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(StructureStart::new(structure, def, chunk, pieces))
|
||||
Some(Generated::Start(StructureStart::new(
|
||||
structure, def, chunk, pieces,
|
||||
)))
|
||||
}
|
||||
impl StructureStart {
|
||||
/// A start of `def` (registry index `structure`) from its pieces, at
|
||||
|
||||
+4
-2
@@ -22,9 +22,11 @@ tunes it. LAN worlds and single player don't filter.
|
||||
## Muting by hand
|
||||
|
||||
Operators of level 3 (`/ban`'s level) mute players themselves; the console takes the same words without the
|
||||
slash. These are Horizon extras, and exist only on servers that filter chat.
|
||||
slash. These are Horizon extras, and exist only on servers that filter chat. They are in the command tree of
|
||||
operators of level 3 and above, so their clients complete them; other players don't see them.
|
||||
|
||||
- `/mute <player> [reason]`: muted until `/unmute`.
|
||||
- `/mute <player> [duration] [reason]`: muted until `/unmute`, or for the duration when the word after the name
|
||||
is one (`/mute Steve 1h spam`).
|
||||
- `/tempmute <player> <duration> [reason]`: muted for a duration such as `30s`, `10m`, `2h`, `1d` or `1w`.
|
||||
- `/mutelist`: every mute in force, the filter's and the operators', with who muted, until when and why.
|
||||
- `/unmute <player>`: lifts either kind.
|
||||
|
||||
+10
-3
@@ -317,7 +317,9 @@ is online is kicked with the reason and the time the ban ends, and the same mess
|
||||
try to join. A player who is already banned (and whose ban has not ended) is left as they are: `pardon` first to
|
||||
change a ban. The player may be offline if the server has seen them. The console logs it as
|
||||
`[tempban] <operator> banned <player> for <duration>`, and the event stream (`GET /api/v1/events`) gets a `ban`
|
||||
event with `reason`, `by` and `until`. It is not in the command tree, so it has no completions, like `/mute`.
|
||||
event with `reason`, `by` and `until`. `tempbanlist` lists the bans that end by themselves, soonest first, with
|
||||
who banned, the time left, the end and the reason. Both are in operators' command tree (level 3 and above), so
|
||||
their clients complete them, as they do the [mute commands](chat-filter.md#muting-by-hand).
|
||||
|
||||
To generate the world around spawn ahead of the players, Horizon has its own `pregen` command, off until
|
||||
`server.toml` has `horizon-pregen = true` under `[properties]`: `horizon-server cmd pregen start 2500`
|
||||
@@ -341,8 +343,13 @@ Tiles are sampled again for each player who asks (there is no cache yet). Vanill
|
||||
then prints a short summary and saves it in `reports/`: `perf-<time>.txt` and `perf-<time>.json`. They say where
|
||||
the ticks' time went, phase by phase (mobs, block ticks, block entities, the network and the chunks it
|
||||
sends, saving...), the slowest ticks, how many entities of each type, chunks, players, memory, CPU, and the
|
||||
settings that matter (view and simulation distance, max players). They hold no names, UUIDs or addresses, so you
|
||||
can attach them to an issue as they are. On the console it is `horizon-report [SECONDS]`.
|
||||
settings that matter (view and simulation distance, max players). Since format 2 they also give each dimension's
|
||||
share of the tick (`level_*`), what each mob type costs per dimension (one tick in four times every mob: ms a
|
||||
tick, mobs, µs a mob), the chunk workers' CPU by job (load, generate, decorate, finish, light), the network bytes
|
||||
a player sends and receives, and what recording cost the tick (measured, usually well under 1 % of MSPT). They
|
||||
hold no names, UUIDs or addresses, so you can attach them to an issue as they are. On the console it is
|
||||
`horizon-report [SECONDS]`; `horizon-report stop` writes the report now and `horizon-report status` says how far
|
||||
it is. Nothing restarts: the report runs beside the game.
|
||||
|
||||
Between reports, the log keeps the spikes: a tick slower than 250 ms writes a `Horizon: slow tick` line with
|
||||
its phases (`HORIZON_SPIKES=MS` in the engine's environment sets another threshold), and a server more than
|
||||
|
||||
Reference in new issue
Block a user