Merge pull request #1425 from ludovic111/fix/st-hud

🤖 Agent: merged after verify and CI parity passed; each fix has a lan_e2e check (host and guest) passing on the server thread and in-frame (bugs-st-1).
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-23 19:38:45 +02:00
commit 8a4ebcc553
10 files changed
+510 -10

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@ impl BossBar {
style: "progress".into(),
value: 0,
max: 100,
visible: false,
visible: true,
player: false,
}
}
@@ -834,6 +834,12 @@ impl Game<'_> {
"velocity": [p.velocity.x, p.velocity.y, p.velocity.z],
"on_ground": p.ground.on_ground,
"health": p.health.health(),
// The maximum the HUD's hearts last read (its cached value).
"max_health": p
.attributes
.lookup(p.attributes.key("minecraft:max_health").as_ref())
.and_then(|h| p.attributes.instance(&h).ok())
.map(|i| i.cached_value()),
"food": p.health.food.food,
})
});
@@ -973,6 +979,8 @@ impl Game<'_> {
status["projectiles"] = json!(self.projectiles_status());
// The particles made so far, by type.
status["particles"] = json!(self.particles.made());
// The scoreboard sidebar the HUD shows.
status["sidebar"] = self.sidebar_status();
status
}
}
+1
View File
@@ -997,6 +997,7 @@ impl Game<'_> {
self.lan_update_streaming(&host);
self.lan_update_models(&mut host);
self.lan_update_waypoints(&mut host, state.as_ref());
self.lan_sync_scoreboard(&mut host);
drop(holding);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
+13
View File
@@ -164,6 +164,8 @@ pub(super) struct Remote {
level_players: HashMap<i32, u128>,
/// The local player's profile UUID (always in the level).
profile: u128,
/// The local player's profile name: its score holder and team member.
pub(super) profile_name: String,
destroying: Option<([i32; 3], u8, f32)>,
destroy_delay: u32,
input: horizon_client::interaction_input::InteractionInput,
@@ -411,6 +413,7 @@ impl Game<'_> {
online_mode: false,
level_players: HashMap::new(),
profile: 0,
profile_name: String::new(),
destroying: None,
destroy_delay: 0,
input: Default::default(),
@@ -795,6 +798,7 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.joined = true;
r.profile = uuid;
r.profile_name = name.clone();
r.status = format!("Joined as {name}; loading terrain...");
}
}
@@ -2128,6 +2132,15 @@ impl Game<'_> {
m.swing();
}
}
// The player's own instances: its maximum health (the hearts the
// HUD draws and the health it may hold), reach, speed and so on.
// Then the received health again, which the old maximum clamped.
C::UpdateAttributes(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
if let Some(pl) = self.player.as_mut() {
horizon_client::lan::attributes::apply(&mut pl.attributes, &p.values);
}
self.apply_remote_health();
}
C::UpdateMobEffect(p) => self.remote_effect(&p),
C::RemoveMobEffect(p) => {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
+45 -6
View File
@@ -324,6 +324,22 @@ impl Game<'_> {
}
}
}
/// `ServerScoreboard`'s packets: each placed player's client is sent
/// what changed on the board since it was last sent it (all of it on
/// joining), lan/scoreboard.rs.
pub(super) fn lan_sync_scoreboard(&mut self, host: &mut super::lan::LanHost) {
if host.hub.guests.iter().all(|g| !g.placed) {
return;
}
self.load_scoreboard();
let view = horizon_client::lan::scoreboard::View::of(&self.score.board);
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
if g.scoreboard != view {
g.send_all(&g.scoreboard.changes(&view));
g.scoreboard = view.clone();
}
}
}
/// `ServerPlayer.doTick` score updates and the statistic criteria.
pub(super) fn tick_scoreboard(&mut self) {
self.load_scoreboard();
@@ -563,19 +579,42 @@ impl Game<'_> {
// ---- the sidebar -----------------------------------------------------------
/// `Hud.extractScoreboardSidebar` / `displayScoreboardSidebar`.
pub(super) fn draw_scoreboard(&self, batch: &mut HudBatch) {
/// The objective in the player's team's sidebar slot, else the sidebar's.
fn sidebar_objective(&self) -> Option<&sb::Objective> {
let board = &self.score.board;
if board.display.is_empty() {
return;
return None;
}
// A server's client is in its team by its profile name.
let name = self
.remote
.as_ref()
.map_or(PLAYER_NAME, |r| r.profile_name.as_str());
let team_slot = board
.players_team(PLAYER_NAME)
.players_team(name)
.and_then(|t| t.color.as_ref())
.map(|c| format!("sidebar.team.{c}"))
.and_then(|slot| board.display_objective(&slot));
let Some(objective) = team_slot.or_else(|| board.display_objective(sb::SLOT_SIDEBAR))
else {
team_slot.or_else(|| board.display_objective(sb::SLOT_SIDEBAR))
}
/// The sidebar the HUD shows: its objective and its lines, for `status`.
pub(super) fn sidebar_status(&self) -> serde_json::Value {
let board = &self.score.board;
let Some(objective) = self.sidebar_objective() else {
return serde_json::Value::Null;
};
let scores: serde_json::Map<String, serde_json::Value> = board
.scores
.iter()
.filter_map(|(holder, s)| Some((holder.clone(), s.get(&objective.name)?.value.into())))
.collect();
serde_json::json!({ "objective": objective.name, "title": objective.display_text().plain(), "scores": scores })
}
/// `Hud.extractScoreboardSidebar` / `displayScoreboardSidebar`.
pub(super) fn draw_scoreboard(&self, batch: &mut HudBatch) {
let board = &self.score.board;
let Some(objective) = self.sidebar_objective() else {
return;
};
let draws = hud_overlays::sidebar(
+43 -1
View File
@@ -5,7 +5,7 @@
//! changed since (`getAttributesToSync`), to the trackers and the player itself.
use horizon_protocol::packets::play::{AttributeModifier, AttributeSnapshot};
use horizon_world::attributes::{
Instance, Operation,
Instance, Modifier, Operation,
entity::{AttributeId, EntityAttributes},
live::LiveAttributes,
};
@@ -70,6 +70,48 @@ pub fn take_dirty<G: FnMut() -> i32>(map: &mut LiveAttributes<G>) -> Vec<Attribu
.collect()
}
/// `ClientPacketListener.handleUpdateAttributes`: each instance the server
/// synced takes its base and exactly its modifiers (`removeModifiers`, then
/// `addTransientModifier` for each). An attribute the map doesn't know is
/// skipped, as vanilla warns and skips it.
pub fn apply<G: FnMut() -> i32>(map: &mut LiveAttributes<G>, values: &[AttributeSnapshot]) {
for s in values {
let Some(key) = map
.keys()
.iter()
.find(|k| k.attribute().registry_id() == s.attribute)
.cloned()
else {
continue;
};
let Ok(Some(handle)) = map.get_instance(Some(key)) else {
continue;
};
let modifiers: Vec<_> = s
.modifiers
.iter()
.filter_map(|m| Modifier::new(&m.id, m.amount, operation(m.operation)).ok())
.collect();
let _ = map.edit(&handle, |instance, observer| {
instance.set_base_value(s.base, observer)?;
instance.remove_all(observer)?;
for m in modifiers {
instance.add_transient(m, observer)?;
}
Ok(())
});
}
}
/// `AttributeModifier.Operation.BY_ID`.
fn operation(id: i32) -> Operation {
match id {
1 => Operation::AddMultipliedBase,
2 => Operation::AddMultipliedTotal,
_ => Operation::AddValue,
}
}
/// The client-syncable instances of a generic attribute map.
pub fn generic_syncable(map: &EntityAttributes) -> impl Iterator<Item = AttributeSnapshot> + '_ {
let catalog = map.catalog();
+3
View File
@@ -242,6 +242,8 @@ pub struct Guest {
pub advancements_loaded: bool,
pub advancements_visible: Vec<bool>,
pub advancements_changed: std::collections::BTreeSet<usize>,
/// The server's scoreboard as this player's client has it (lan/scoreboard.rs).
pub scoreboard: super::scoreboard::View,
/// Recipes unlocked since the client was last told
/// (`ServerRecipeBook.addRecipes` sends them).
pub recipes_unlocked: Vec<String>,
@@ -354,6 +356,7 @@ impl Guest {
advancements_loaded: false,
advancements_visible: Vec::new(),
advancements_changed: Default::default(),
scoreboard: Default::default(),
recipes_unlocked: Vec::new(),
}
}
+1
View File
@@ -20,6 +20,7 @@ pub mod hub;
pub mod menu;
pub mod playerdata;
mod recipes;
pub mod scoreboard;
pub mod tracking;
pub mod waypoints;
+308
View File
@@ -0,0 +1,308 @@
//! `ServerScoreboard`: what a player's client knows of the server's
//! scoreboard. Every team goes to every player; an objective goes once it
//! sits in a display slot (`startTrackingObjective`: its objective, its
//! slots, then its scores) and leaves when it sits in none
//! (`stopTrackingObjective`). A joining player gets it all
//! (`PlayerList.updateEntireScoreboard`).
//!
//! A server's commands change the board in place; each tick a player is
//! sent the packets that turn what it was last sent into what it should
//! have now, which are the ones `ServerScoreboard`'s callbacks send.
use crate::scoreboard::{NumberFormat, RenderType, Scoreboard, component_tag, display_slots};
use crate::text::COLORS;
use horizon_protocol::packets::play::{
self, Clientbound as C, ResetScore, SetDisplayObjective, SetObjective, SetPlayerTeam, SetScore,
TeamParameters,
};
use horizon_world::nbt::Tag;
use std::collections::{BTreeMap, BTreeSet};
type Format = Option<play::NumberFormat>;
/// The scoreboard as one player's client has it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct View {
/// The tracked objectives: display name, render type (1 hearts), format.
objectives: BTreeMap<String, (Tag, i32, Format)>,
/// `DisplaySlot` id to objective.
display: BTreeMap<i32, String>,
/// The tracked objectives' scores, by holder and objective.
scores: BTreeMap<(String, String), (i32, Option<Tag>, Format)>,
/// Every team: its parameters and players, in the team's order.
teams: BTreeMap<String, (TeamParameters, Vec<String>)>,
}
fn format(f: Option<&NumberFormat>) -> Format {
f.map(|f| match f {
NumberFormat::Blank => play::NumberFormat::Blank,
NumberFormat::Styled(s) => play::NumberFormat::Styled(crate::scoreboard::json_to_tag(s)),
NumberFormat::Fixed(c) => play::NumberFormat::Fixed(component_tag(c)),
})
}
impl View {
/// What the board's state gives a client.
pub fn of(board: &Scoreboard) -> Self {
let slots = display_slots();
let display: BTreeMap<i32, String> = board
.display
.iter()
.filter_map(|(slot, o)| Some((slots.iter().position(|s| s == slot)? as i32, o.clone())))
.collect();
let tracked: BTreeSet<&String> = display.values().collect();
let objectives = board
.objectives
.iter()
.filter(|o| tracked.contains(&o.name))
.map(|o| {
let render = i32::from(o.render_type == RenderType::Hearts);
(
o.name.clone(),
(
component_tag(&o.display_name),
render,
format(o.number_format.as_ref()),
),
)
})
.collect();
let scores = board
.scores
.iter()
.flat_map(|(holder, scores)| {
scores
.iter()
.filter(|(o, _)| tracked.contains(o))
.map(move |(o, s)| {
(
(holder.clone(), o.clone()),
(
s.value,
s.display.as_ref().map(component_tag),
format(s.number_format.as_ref()),
),
)
})
})
.collect();
let teams = board
.teams
.iter()
.map(|t| {
let params = TeamParameters {
display_name: component_tag(&t.display_name),
prefix: component_tag(&t.prefix),
suffix: component_tag(&t.suffix),
name_tag_visibility: t.nametag_visibility as i32,
collision_rule: t.collision_rule as i32,
color: t
.color
.as_deref()
.and_then(|c| COLORS.iter().position(|(_, n, _)| *n == c))
.map(|i| i as i32),
options: i8::from(t.allow_friendly_fire)
| (i8::from(t.see_friendly_invisibles) << 1),
};
(
t.name.clone(),
(params, t.players.iter().cloned().collect()),
)
})
.collect();
Self {
objectives,
display,
scores,
teams,
}
}
/// The packets that take a client from `self` to `next`.
pub fn changes(&self, next: &View) -> Vec<C> {
let mut out = Vec::new();
let team = |name: &str, method, parameters, players| {
C::SetPlayerTeam(SetPlayerTeam {
name: name.to_owned(),
method,
parameters,
players,
})
};
for name in self.teams.keys().filter(|n| !next.teams.contains_key(*n)) {
out.push(team(name, 1, None, Vec::new()));
}
for (name, (params, players)) in &next.teams {
let Some((was, had)) = self.teams.get(name) else {
out.push(team(name, 0, Some(params.clone()), players.clone()));
continue;
};
if was != params {
out.push(team(name, 2, Some(params.clone()), Vec::new()));
}
let joined: Vec<_> = players
.iter()
.filter(|p| !had.contains(p))
.cloned()
.collect();
if !joined.is_empty() {
out.push(team(name, 3, None, joined));
}
let left: Vec<_> = had
.iter()
.filter(|p| !players.contains(p))
.cloned()
.collect();
if !left.is_empty() {
out.push(team(name, 4, None, left));
}
}
for (name, display) in &next.objectives {
let method = match self.objectives.get(name) {
None => 0,
Some(was) if was != display => 2,
Some(_) => continue,
};
out.push(C::SetObjective(SetObjective {
name: name.clone(),
method,
display: Some(display.clone()),
}));
}
let slots: BTreeSet<i32> = self
.display
.keys()
.chain(next.display.keys())
.copied()
.collect();
for slot in slots {
let objective = next.display.get(&slot);
if self.display.get(&slot) != objective {
out.push(C::SetDisplayObjective(SetDisplayObjective {
slot,
objective: objective.cloned().unwrap_or_default(),
}));
}
}
for ((holder, objective), (value, display, number_format)) in &next.scores {
let key = (holder.clone(), objective.clone());
if self.scores.get(&key) != Some(&(*value, display.clone(), number_format.clone())) {
out.push(C::SetScore(SetScore {
owner: holder.clone(),
objective: objective.clone(),
score: *value,
display: display.clone(),
number_format: number_format.clone(),
}));
}
}
for (holder, objective) in self.scores.keys() {
let key = (holder.clone(), objective.clone());
if !next.scores.contains_key(&key) && next.objectives.contains_key(objective) {
out.push(C::ResetScore(ResetScore {
owner: holder.clone(),
objective: Some(objective.clone()),
}));
}
}
for name in self
.objectives
.keys()
.filter(|n| !next.objectives.contains_key(*n))
{
out.push(C::SetObjective(SetObjective {
name: name.clone(),
method: 1,
display: None,
}));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn methods(packets: &[C]) -> Vec<String> {
packets
.iter()
.map(|p| match p {
C::SetObjective(o) => format!("objective {} {}", o.name, o.method),
C::SetDisplayObjective(d) => format!("display {} {}", d.slot, d.objective),
C::SetScore(s) => format!("score {} {} {}", s.owner, s.objective, s.score),
C::ResetScore(r) => format!("reset {} {:?}", r.owner, r.objective),
C::SetPlayerTeam(t) => format!("team {} {} {:?}", t.name, t.method, t.players),
_ => "other".into(),
})
.collect()
}
#[test]
fn a_shown_objective_goes_with_its_scores_and_leaves_when_hidden() {
let mut board = Scoreboard::default();
board.add_objective(
"audit",
"dummy",
json!("Audit"),
RenderType::Integer,
false,
None,
);
board.score_mut("Alpha", "audit").value = 7;
let empty = View::default();
// Not displayed: not tracked, nothing sent.
let hidden = View::of(&board);
assert!(empty.changes(&hidden).is_empty());
board.set_display_objective("sidebar", Some("audit"));
let shown = View::of(&board);
assert_eq!(
methods(&hidden.changes(&shown)),
[
"objective audit 0",
"display 1 audit",
"score Alpha audit 7"
]
);
board.score_mut("Alpha", "audit").value = 12;
board.score_mut("Beta", "audit").value = 3;
board.reset_one("Beta", "audit");
let changed = View::of(&board);
assert_eq!(methods(&shown.changes(&changed)), ["score Alpha audit 12"]);
board.reset_one("Alpha", "audit");
let reset = View::of(&board);
assert_eq!(
methods(&changed.changes(&reset)),
["reset Alpha Some(\"audit\")"]
);
board.set_display_objective("sidebar", None);
assert_eq!(
methods(&reset.changes(&View::of(&board))),
["display 1 ", "objective audit 1"]
);
}
#[test]
fn teams_go_to_everyone_with_their_players() {
let mut board = Scoreboard::default();
board.add_team("red");
board.add_to_team("Alpha", "red");
let first = View::of(&board);
assert_eq!(
methods(&View::default().changes(&first)),
["team red 0 [\"Alpha\"]"]
);
board.add_to_team("Beta", "red");
board.remove_from_team("Alpha");
let second = View::of(&board);
assert_eq!(
methods(&first.changes(&second)),
["team red 3 [\"Beta\"]", "team red 4 [\"Alpha\"]"]
);
board.remove_team("red");
assert_eq!(
methods(&second.changes(&View::of(&board))),
["team red 1 []"]
);
}
}
+87 -2
View File
@@ -213,7 +213,7 @@ ROOT = Path(__file__).resolve().parents[1]
GUEST_NAME = 'Guest'
# The host plays single player on its server thread (--server-thread).
SERVER_THREAD = False
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', 'breath', 'lava', 'levels', 'drops', 'underwater']
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', 'breath', 'lava', 'levels', 'drops', 'underwater']
class Game:
@@ -2711,6 +2711,89 @@ def check_sparks(host, guest, out):
return result
def hearts(game):
"""The player's maximum and current health as its own client holds them."""
p = game('status')['player']
return p.get('max_health'), round(p['health'], 1)
def check_hud(host, guest, out):
result = {}
for label, player in (('host', host), ('guest', guest)):
player('command', command='gamemode survival')
try:
for maximum in (40, 10):
player('command', command=f'attribute @s minecraft:max_health base set {maximum}')
player.wait(10)
player('command', command='effect give @s minecraft:instant_health 1 10')
seen = None
for _ in range(15):
player.wait(2)
seen = hearts(player)
if seen == (maximum, maximum):
break
result[f'{label} {maximum}'] = seen
player.shot(out, f'hearts-{label}-{maximum}')
finally:
player('command', command='attribute @s minecraft:max_health base reset')
player('command', command='effect give @s minecraft:instant_health 1 10')
# BossBarCommands' new bar starts visible (ServerBossEvent's constructor).
host('command', command='bossbar add lan_e2e:bar "LAN bar"')
try:
host('command', command='bossbar get lan_e2e:bar visible')
host.wait(5)
result['bar'] = [line for line in host.raw('messages', 5)['chat'] if 'lan_e2e:bar' in line or 'LAN bar' in line]
finally:
host('command', command='bossbar remove lan_e2e:bar')
wrong = {k: v for k, v in result.items() if k != 'bar' and v != (int(k.split()[1]),) * 2}
assert not wrong, f'hearts: {wrong}\n{result}'
assert any('currently visible' in line for line in result['bar']), f'bar: {result["bar"]}'
return result
def sidebars(host, guest, want, ticks=40):
"""Each side's sidebar (objective and scores) once both show `want` (host, guest), or after `ticks`."""
seen = None
for _ in range(ticks // 2):
host.wait(2)
seen = tuple(g('status').get('sidebar') for g in (host, guest))
got = tuple(s and (s['objective'], s['scores']) for s in seen)
if got == want:
break
return seen
def check_sidebar(host, guest, out):
result = {}
run = lambda *cmds: [host('command', command=c) for c in cmds]
try:
run('scoreboard objectives add audit dummy {"text":"Audit board","color":"aqua"}',
'scoreboard players set Alpha audit 7', 'scoreboard players set Beta audit 3',
'scoreboard objectives setdisplay sidebar audit')
both = ('audit', {'Alpha': 7, 'Beta': 3})
result['shown'] = sidebars(host, guest, (both, both))
host.shot(out, 'sidebar')
guest.shot(out, 'sidebar')
run('scoreboard players set Alpha audit 12', 'scoreboard players reset Beta audit')
both = ('audit', {'Alpha': 12})
result['changed'] = sidebars(host, guest, (both, both))
# The guest's team's sidebar replaces the sidebar for the guest only.
run('team add red', 'team modify red color red', f'team join red {GUEST_NAME}',
'scoreboard objectives add reds dummy', f'scoreboard players set {GUEST_NAME} reds 5',
'scoreboard objectives setdisplay sidebar.team.red reds')
result['team'] = sidebars(host, guest, (both, ('reds', {GUEST_NAME: 5})))
run('scoreboard objectives remove reds', 'scoreboard objectives setdisplay sidebar')
result['cleared'] = sidebars(host, guest, (None, None))
finally:
run('scoreboard objectives remove audit', 'scoreboard objectives remove reds', 'team remove red')
want = {'shown': (('audit', {'Alpha': 7, 'Beta': 3}),) * 2, 'changed': (('audit', {'Alpha': 12}),) * 2,
'team': (('audit', {'Alpha': 12}), ('reds', {GUEST_NAME: 5})), 'cleared': (None, None)}
got = {k: tuple(s and (s['objective'], s['scores']) for s in v) for k, v in result.items()}
wrong = {k: v for k, v in got.items() if v != want[k]}
assert not wrong, f'sidebars: {wrong}\n{result}'
return result
def check_blast(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
@@ -4046,7 +4129,9 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour,
'looks': check_looks,
'leads': check_leads,
'thrown': check_thrown,
'sparks': check_sparks}
'sparks': check_sparks,
'hud': check_hud,
'sidebar': check_sidebar}
def run(ctl, out, resources, checks):