mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
10 files changed
+510
-10
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod hub;
|
||||
pub mod menu;
|
||||
pub mod playerdata;
|
||||
mod recipes;
|
||||
pub mod scoreboard;
|
||||
pub mod tracking;
|
||||
pub mod waypoints;
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user