Files
RustNotMC/crates/horizon-client/src/item_tooltip.rs
T
SashegDev 874b6f20c2 Клиент: начальный импорт кодовой базы
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
2026-10-29 22:04:28 +03:00

1100 lines
40 KiB
Rust

//! Item tooltip formatting: equipment modifiers with a player's base values,
//! and lines without player context for the recipe book's search tree.
use crate::{
hud::rgba,
items::{ItemInfo, ItemStack},
};
use horizon_world::nbt::Tag;
/// The `minecraft:tooltip_display` component applied by `ItemStack` while it
/// builds a hover tooltip.
pub struct TooltipDisplay<'a> {
component: Option<&'a Tag>,
ominous_banner: bool,
}
impl<'a> TooltipDisplay<'a> {
pub fn for_stack(stack: &'a ItemStack) -> Self {
Self {
component: stack
.data()
.and_then(|data| data.other("minecraft:tooltip_display")),
ominous_banner: stack.data().is_some_and(|data| data.ominous_banner),
}
}
fn hides_tooltip(&self) -> bool {
matches!(
self.component.and_then(|display| display.get("hide_tooltip")),
Some(Tag::Byte(value)) if *value != 0
)
}
/// `ItemStack.getTooltipLines`: `hide_tooltip` suppresses the name unless
/// the tooltip flag is creative.
pub fn name_visible(&self, creative: bool) -> bool {
creative || !self.hides_tooltip()
}
/// `TooltipDisplay.shows`: whether details for a data component are shown.
pub fn shows(&self, component: &str) -> bool {
if self.hides_tooltip() {
return false;
}
// `item_data::decode_components` models the raid captain banner's
// default hidden pattern tooltip as `ominous_banner` instead of
// retaining its redundant `tooltip_display` component.
if self.ominous_banner
&& self.component.is_none()
&& component == "minecraft:banner_patterns"
{
return false;
}
let Some(Tag::List(hidden)) = self
.component
.and_then(|display| display.get("hidden_components"))
else {
return true;
};
!hidden.iter().any(|tag| {
matches!(tag, Tag::String(id) if id.0.iter().copied().eq(component.encode_utf16()))
})
}
}
/// `EquipmentSlotGroup` in declaration order, the order tooltips list them in.
const SLOT_GROUPS: [&str; 11] = [
"any", "mainhand", "offhand", "hand", "feet", "legs", "chest", "head", "armor", "body",
"saddle",
];
/// `ItemAttributeModifiers.ATTRIBUTE_MODIFIER_FORMAT`, `DecimalFormat("#.##")`
/// of a positive amount (half-even on the exact value).
fn format_amount(value: f64) -> String {
let text = format!("{:.2}", value);
text.trim_end_matches('0').trim_end_matches('.').to_owned()
}
/// `ItemAttributeModifiers.Display.Default.apply`: a modifier's line, none
/// for a zero amount. `percent` is the modifier's operation id (0: added
/// value, 1 and 2: multiplied).
fn modifier_line(
translate: &dyn Fn(&str) -> Option<String>,
attribute: &str,
amount: f64,
operation: usize,
total: bool,
) -> Option<String> {
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
let scale = match operation {
0 if attribute == "knockback_resistance" => 10.0,
0 => 1.0,
_ => 100.0,
};
let (kind, shown) = if total {
("equals", amount * scale)
} else if amount > 0.0 {
("plus", amount * scale)
} else if amount < 0.0 {
("take", -amount * scale)
} else {
return None;
};
Some(
text(&format!("attribute.modifier.{kind}.{operation}"))
.replacen("%s", &format_amount(shown), 1)
.replacen("%s", &text(&format!("attribute.name.{attribute}")), 1)
.replace("%%", "%"),
)
}
/// `Attribute.getStyle`: the colour of a line for an attribute whose value
/// rises or falls. Gravity, scale and the waypoint ranges are neutral, burning
/// time and fall damage are negative, every other attribute is positive.
fn attribute_color(attribute: &str, rises: bool) -> u32 {
let (blue, red) = (rgba(85, 85, 255, 255), rgba(255, 85, 85, 255));
match attribute {
"burning_time" | "fall_damage_multiplier" => {
if rises {
red
} else {
blue
}
}
"gravity" | "scale" | "waypoint_transmit_range" | "waypoint_receive_range" => {
rgba(170, 170, 170, 255)
}
_ => {
if rises {
blue
} else {
red
}
}
}
}
/// `ItemStack.addAttributeTooltips`: slot groups, colors and optional player
/// attack damage/speed bases. Recipe search has no player context.
pub fn attribute_lines(
modifiers: &[(String, String, f64, String, String)],
player_base: Option<[f64; 2]>,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, u32)> {
let mut lines = Vec::new();
for group in SLOT_GROUPS {
let mut first = true;
for (attribute, modifier, amount, operation, slot) in modifiers {
if slot != group {
continue;
}
if std::mem::take(&mut first) {
lines.push((String::new(), u32::MAX));
lines.push((
translate(&format!("item.modifiers.{group}"))
.unwrap_or_else(|| format!("item.modifiers.{group}")),
rgba(170, 170, 170, 255),
));
}
let id = match operation.as_str() {
"add_multiplied_base" => 1,
"add_multiplied_total" => 2,
_ => 0,
};
let path = attribute.strip_prefix("minecraft:").unwrap_or(attribute);
let base = player_base.and_then(|[damage, speed]| match modifier.as_str() {
"minecraft:base_attack_damage" => Some(damage),
"minecraft:base_attack_speed" => Some(speed),
_ => None,
});
if let Some(line) = modifier_line(
translate,
path,
amount + base.unwrap_or(0.0),
id,
base.is_some(),
) {
let (line, color) = if base.is_some() {
(format!(" {line}"), rgba(0, 170, 0, 255))
} else {
(line, attribute_color(path, *amount > 0.0))
};
lines.push((line, color));
}
}
}
lines
}
/// The attribute modifiers a mob effect adds per amplifier level
/// (`MobEffects`): attribute, amount, operation id.
fn effect_modifier(effect: &str) -> Option<(&'static str, f64, usize)> {
Some(match effect {
"speed" => ("movement_speed", 0.2, 2),
"slowness" => ("movement_speed", -0.15, 2),
"strength" => ("attack_damage", 3.0, 0),
"weakness" => ("attack_damage", -4.0, 0),
"jump_boost" => ("safe_fall_distance", 1.0, 0),
"luck" => ("luck", 1.0, 0),
"invisibility" => ("waypoint_transmit_range", -1.0, 2),
_ => return None,
})
}
fn is_potion_item(item: &str) -> bool {
item.ends_with("potion") || item == "minecraft:tipped_arrow"
}
/// The name line of `stack`, as the item's translation or, for potions and
/// smithing templates, its name component gives it.
pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option<String>) -> String {
let path = stack.item.trim_start_matches("minecraft:");
if let Some(name) = stack.custom_name() {
return name.to_owned();
}
if let Some(name) = stack.item_name() {
return name;
}
if stack.data().is_some_and(|d| d.ominous_banner) {
return translate("block.minecraft.ominous_banner")
.unwrap_or_else(|| "Ominous Banner".to_owned());
}
let key = if is_potion_item(&stack.item) {
Some(crate::brewing::potion_name_key(&stack.item, stack.potion()))
} else if let Some(pattern) = path.strip_suffix("_armor_trim_smithing_template") {
Some(format!("trim_pattern.minecraft.{pattern}"))
} else if path == "netherite_upgrade_smithing_template" {
Some("upgrade.minecraft.netherite_upgrade".to_owned())
} else if path.ends_with("_banner_pattern") {
// The item name component of the pattern items that have one.
Some(format!("item.minecraft.{path}.new"))
} else {
None
};
key.and_then(|k| translate(&k))
.or_else(|| translate(&format!("item.minecraft.{path}")))
.or_else(|| translate(&format!("block.minecraft.{path}")))
.unwrap_or_else(|| path.replace('_', " "))
}
/// ItemStack.getRarity: the ominous banner override, then the enchantment upgrade.
pub fn rarity<'a>(stack: &ItemStack, default: &'a str) -> &'a str {
let rarity = if stack.data().is_some_and(|d| d.ominous_banner) {
"uncommon"
} else {
default
};
if stack.enchantments().is_empty() {
rarity
} else {
match rarity {
"common" | "uncommon" => "rare",
"rare" => "epic",
r => r,
}
}
}
fn is_beehive(path: &str) -> bool {
matches!(path, "beehive" | "bee_nest")
}
/// `InstrumentComponent.addToTooltip`: the instrument's description. A goat
/// horn without the component has the item's default, the ponder horn.
pub fn instrument_line(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
if !display.shows("minecraft:instrument") {
return None;
}
let id = match stack.data().and_then(|d| d.other("minecraft:instrument")) {
Some(Tag::String(id)) => id.to_string_lossy(),
// An inline instrument carries its own description.
Some(inline) => {
return inline
.get("description")
.map(|d| crate::entity_nbt::name_text(d).plain());
}
None if &*stack.item == "minecraft:goat_horn" => "minecraft:ponder_goat_horn".to_owned(),
None => return None,
};
let key = format!("instrument.{}", id.replacen(':', ".", 1));
Some(translate(&key).unwrap_or(key))
}
/// `Bees.addToTooltip`: how many bees a hive item holds (a hive item without
/// the component has the item's default, none).
pub fn bees_line(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
if !display.shows("minecraft:bees") {
return None;
}
let bees = match stack.data().and_then(|d| d.other("minecraft:bees")) {
Some(Tag::List(bees)) => bees.len(),
Some(_) => return None,
None if is_beehive(stack.item.trim_start_matches("minecraft:")) => 0,
None => return None,
};
let text =
translate("container.beehive.bees").unwrap_or_else(|| "container.beehive.bees".into());
Some(
text.replacen("%s", &bees.to_string(), 1)
.replacen("%s", "3", 1),
)
}
/// `BlockItemStateProperties.addToTooltip`: a hive item's honey level (a hive
/// item without the component has the item's default, 0).
pub fn honey_line(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
if !display.shows("minecraft:block_state") {
return None;
}
let level = match stack.data().and_then(|d| d.other("minecraft:block_state")) {
Some(Tag::Compound(state)) => {
match state.get(&horizon_world::nbt::JavaString::from("honey_level")) {
Some(Tag::String(level)) => level.to_string_lossy().parse::<u32>().ok()?,
_ => return None,
}
}
Some(_) => return None,
None if is_beehive(stack.item.trim_start_matches("minecraft:")) => 0,
None => return None,
};
let text =
translate("container.beehive.honey").unwrap_or_else(|| "container.beehive.honey".into());
Some(
text.replacen("%s", &level.to_string(), 1)
.replacen("%s", "5", 1),
)
}
/// `WrittenBookContent.addToTooltip`: "by <author>" and the generation.
pub fn written_book_lines(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<String> {
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
let Some(book) = stack
.data()
.and_then(|d| d.written.as_ref())
.filter(|_| display.shows("minecraft:written_book_content"))
else {
return Vec::new();
};
let mut lines = Vec::new();
if !book.author.trim().is_empty() {
lines.push(
crate::text::format_translation(
&text("book.byAuthor"),
&[crate::text::Text::literal(&book.author)],
)
.plain(),
);
}
lines.push(text(&format!("book.generation.{}", book.generation)));
lines
}
/// `JukeboxPlayable.addToTooltip`: the song's description.
pub fn jukebox_line(
stack: &ItemStack,
info: Option<&ItemInfo>,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
if !display.shows("minecraft:jukebox_playable") {
return None;
}
let song = match stack
.data()
.and_then(|d| d.other("minecraft:jukebox_playable"))
{
Some(Tag::String(song)) => song.to_string_lossy(),
// An inline song carries its own description.
Some(inline) => {
return inline
.get("description")
.map(|d| crate::entity_nbt::name_text(d).plain());
}
None => info?.jukebox_song.clone()?,
};
let key = format!("jukebox_song.{}", song.replacen(':', ".", 1));
Some(translate(&key).unwrap_or(key))
}
/// `DyedItemColor.addToTooltip`: "Dyed" (italic, the flag), or with advanced
/// tooltips the colour's hex code.
pub fn dyed_line(
stack: &ItemStack,
display: &TooltipDisplay,
advanced: bool,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<(String, bool)> {
let rgb = stack.data()?.dyed_color?;
if !display.shows("minecraft:dyed_color") {
return None;
}
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
Some(if advanced {
let hex = format!("#{:06X}", rgb & 0xFF_FFFF);
(text("item.color").replacen("%s", &hex, 1), false)
} else {
(text("item.dyed"), true)
})
}
/// `ItemStack.UNBREAKABLE_TOOLTIP`.
pub fn unbreakable_line(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
(stack.data()?.other("minecraft:unbreakable").is_some()
&& display.shows("minecraft:unbreakable"))
.then(|| translate("item.unbreakable").unwrap_or_else(|| "item.unbreakable".into()))
}
/// `PotionContents.addPotionTooltip`'s attribute block: an empty line, "When
/// Applied:" and the modifiers of the potion's effects (blue for a gain, red
/// for a loss); nothing for a potion whose effects add none.
pub fn potion_modifier_lines(
stack: &ItemStack,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, u32)> {
if !is_potion_item(&stack.item) {
return Vec::new();
}
let applied: Vec<(String, u32)> = stack
.potion()
.and_then(crate::brewing::potion)
.into_iter()
.flat_map(|p| p.effects.iter())
.filter_map(|(effect, _, amplifier)| {
let (attribute, amount, operation) = effect_modifier(effect)?;
let amount = amount * (*amplifier + 1) as f64;
let line = modifier_line(translate, attribute, amount, operation, false)?;
let color = if amount > 0.0 {
rgba(85, 85, 255, 255)
} else {
rgba(255, 85, 85, 255)
};
Some((line, color))
})
.collect();
if applied.is_empty() {
return applied;
}
let mut lines = vec![
(String::new(), u32::MAX),
(
translate("potion.whenDrank").unwrap_or_else(|| "potion.whenDrank".into()),
rgba(170, 0, 170, 255),
),
];
lines.extend(applied);
lines
}
/// `ItemStack.components.size()`: the item's default components with the
/// stack's own changes applied.
pub fn component_count(stack: &ItemStack, info: Option<&ItemInfo>) -> usize {
let mut keys: std::collections::HashSet<String> = info
.map(|i| i.components.iter().cloned().collect())
.unwrap_or_default();
if let Some(Tag::Compound(patch)) = crate::item_data::components_tag(stack.damage, stack.data())
{
for key in patch.keys() {
let key = key.to_string_lossy();
match key.strip_prefix('!') {
Some(removed) => keys.remove(removed),
None => keys.insert(key),
};
}
}
keys.len()
}
/// `SmithingTemplateItem.appendHoverText`: the template's kind, what it applies to
/// and its ingredients (gray titles over blue descriptions); none for other items.
pub fn smithing_template_lines(
stack: &ItemStack,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, u32)> {
let path = stack.item.trim_start_matches("minecraft:");
if !path.ends_with("_smithing_template") {
return Vec::new();
}
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
let kind = if path == "netherite_upgrade_smithing_template" {
"netherite_upgrade"
} else {
"armor_trim"
};
let (title, description) = (rgba(170, 170, 170, 255), rgba(85, 85, 255, 255));
let described = |what: &str| {
let key = format!("item.minecraft.smithing_template.{kind}.{what}");
(format!(" {}", text(&key)), description)
};
vec![
(text("item.minecraft.smithing_template"), title),
(String::new(), u32::MAX),
(text("item.minecraft.smithing_template.applies_to"), title),
described("applies_to"),
(text("item.minecraft.smithing_template.ingredients"), title),
described("ingredients"),
]
}
/// The lines of `stack` below its name that its components give, in
/// `ItemStack.addDetailsToTooltip` order.
pub fn detail_lines(
stack: &ItemStack,
info: Option<&ItemInfo>,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<String> {
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
let path = stack.item.trim_start_matches("minecraft:");
let mut lines = Vec::new();
lines.extend(
smithing_template_lines(stack, translate)
.into_iter()
.map(|l| l.0),
);
let display = TooltipDisplay::for_stack(stack);
lines.extend(bees_line(stack, &display, translate));
lines.extend(honey_line(stack, &display, translate));
let data = stack.data();
// PotDecorations.addToTooltip.
if let Some(sherds) = data
.and_then(|d| d.pot_decorations.as_ref())
.filter(|s| s.iter().any(Option::is_some))
{
lines.push(String::new());
for sherd in sherds.iter() {
let item = sherd.as_ref().map_or("minecraft:brick", |s| &*s.item);
lines.push(name_line(&ItemStack::new(item, 1), translate));
}
}
lines.extend(firework_lines(stack, translate));
// PotionContents.addToTooltip: the effects, then their attribute modifiers.
if is_potion_item(&stack.item) {
let scale = match path {
"lingering_potion" => 0.25,
"tipped_arrow" => 0.125,
_ => 1.0,
};
lines.extend(
crate::brewing::tooltip(stack.potion(), scale, translate)
.into_iter()
.map(|l| l.0),
);
lines.extend(
potion_modifier_lines(stack, translate)
.into_iter()
.map(|l| l.0),
);
}
lines.extend(jukebox_line(stack, info, &display, translate));
// Trim.addToTooltip.
if let Some((material, pattern)) = data.and_then(|d| d.trim.as_ref()) {
let short = |s: &str| s.trim_start_matches("minecraft:").to_owned();
lines.push(text("item.minecraft.smithing_template.upgrade"));
lines.push(format!(
" {}",
text(&format!("trim_pattern.minecraft.{}", short(pattern)))
));
lines.push(format!(
" {}",
text(&format!("trim_material.minecraft.{}", short(material)))
));
}
lines.extend(dyed_line(stack, &display, false, translate).map(|l| l.0));
if let Some(info) = info {
lines.extend(
attribute_lines(&info.modifiers, None, translate)
.into_iter()
.map(|(line, _)| line),
);
}
lines
}
/// `Fireworks.addToTooltip` (a rocket's flight duration, its explosions with
/// runs of equal ones counted) and `FireworkExplosion.addToTooltip` (a
/// star's), unless the stack's tooltip display hides them. A rocket without
/// the component has the item's default: flight duration 1, no explosions.
pub fn firework_lines(
stack: &ItemStack,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<String> {
use crate::particles::FireworkExplosion;
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
let shape = |e: &FireworkExplosion| {
text(&format!(
"item.minecraft.firework_star.shape.{}",
e.shape.name()
))
};
// FireworkExplosion.appendColors: dye names (getColorName), comma-separated.
let colors = |colors: &[u32]| {
colors
.iter()
.map(|c| {
match crate::particles::DYE_FIREWORK_COLORS
.iter()
.find(|(_, firework)| firework == c)
{
Some((name, _)) => text(&format!("item.minecraft.firework_star.{name}")),
None => text("item.minecraft.firework_star.custom_color"),
}
})
.collect::<Vec<_>>()
.join(", ")
};
// FireworkExplosion.addAdditionalTooltip.
let details = |e: &FireworkExplosion, indent: &str, lines: &mut Vec<String>| {
if !e.colors.is_empty() {
lines.push(format!("{indent}{}", colors(&e.colors)));
}
if !e.fade.is_empty() {
let fade = text("item.minecraft.firework_star.fade_to");
lines.push(format!("{indent}{fade} {}", colors(&e.fade)));
}
if e.trail {
lines.push(format!(
"{indent}{}",
text("item.minecraft.firework_star.trail")
));
}
if e.twinkle {
lines.push(format!(
"{indent}{}",
text("item.minecraft.firework_star.flicker")
));
}
};
let display = TooltipDisplay::for_stack(stack);
let data = stack.data();
let mut lines = Vec::new();
let fireworks = match data.and_then(|d| d.fireworks.as_ref()) {
Some((flight, explosions)) => Some((*flight, explosions.as_slice())),
None => (&*stack.item == "minecraft:firework_rocket").then_some((1, &[][..])),
};
if let Some((flight, explosions)) = fireworks
&& display.shows("minecraft:fireworks")
{
if flight > 0 {
lines.push(
text("item.minecraft.firework_rocket.flight_duration").replacen(
"%s",
&flight.to_string(),
1,
),
);
}
// Fireworks.addExplosionTooltip for each run of equal explosions.
for run in explosions.chunk_by(|a, b| a == b) {
let e = &run[0];
lines.push(if run.len() == 1 {
text("item.minecraft.firework_rocket.single_star").replacen("%s", &shape(e), 1)
} else {
text("item.minecraft.firework_rocket.multiple_stars")
.replacen("%s", &run.len().to_string(), 1)
.replacen("%s", &shape(e), 1)
});
details(e, " ", &mut lines);
}
}
if let Some(e) = data.and_then(|d| d.firework_explosion.as_ref())
&& display.shows("minecraft:firework_explosion")
{
lines.push(shape(e));
details(e, "", &mut lines);
}
lines
}
/// `ItemContainerContents.addToTooltip`: "count x name" for the first five
/// stacks, by slot, then how many more there are (an italic line).
pub fn container_lines(
stack: &ItemStack,
name: &dyn Fn(&ItemStack) -> String,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<String> {
let Some(data) = stack.data() else {
return Vec::new();
};
let mut slots: Vec<&(u8, ItemStack)> = data.container.iter().collect();
slots.sort_by_key(|(slot, _)| *slot);
let count = translate("item.container.item_count").unwrap_or_else(|| "%s x %s".into());
let mut lines: Vec<String> = slots
.iter()
.take(5)
.map(|(_, s)| {
count
.replacen("%s", &s.count.to_string(), 1)
.replacen("%s", &name(s), 1)
})
.collect();
if slots.len() > 5 {
let more =
translate("item.container.more_items").unwrap_or_else(|| "and %s more...".into());
lines.push(more.replacen("%s", &(slots.len() - 5).to_string(), 1));
}
lines
}
/// Every line of `stack`'s tooltip, name first.
pub fn lines(
stack: &ItemStack,
info: Option<&ItemInfo>,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<String> {
let mut lines = vec![name_line(stack, translate)];
if TooltipDisplay::for_stack(stack).shows("minecraft:lore") {
lines.extend(lore_texts(stack).into_iter().map(|t| t.plain()));
}
lines.extend(detail_lines(stack, info, translate));
lines
}
/// `ItemLore.addToTooltip`: each lore component under dark purple italic, with
/// the component's own styles (including explicit `false`) winning on children.
pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
let Some(Tag::List(entries)) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
return Vec::new();
};
let base = crate::text::Style {
color: Some(crate::text::DARK_PURPLE),
italic: Some(true),
..Default::default()
};
entries
.iter()
.map(|entry| {
let mut text = crate::entity_nbt::name_text(entry).resolve_legacy();
for span in &mut text.spans {
span.style.inherit(&base);
}
text
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lore_keeps_component_colours_under_dark_purple_italic() {
let mut stack = ItemStack::new("minecraft:stick", 1);
stack.data_mut().set_other(
"minecraft:lore",
Some(Tag::List(vec![
Tag::compound([
("text", Tag::String("Gold line".into())),
("color", Tag::String("gold".into())),
("italic", Tag::Byte(0)),
]),
Tag::String("Plain lore".into()),
])),
);
let lines = lore_texts(&stack);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].plain(), "Gold line");
assert_eq!(lines[0].spans[0].style.color, Some(crate::text::GOLD));
assert!(!lines[0].spans[0].style.is_italic());
assert_eq!(lines[1].plain(), "Plain lore");
assert_eq!(
lines[1].spans[0].style.color,
Some(crate::text::DARK_PURPLE)
);
assert!(lines[1].spans[0].style.is_italic());
assert!(TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
stack.data_mut().set_other(
"minecraft:tooltip_display",
Some(Tag::compound([(
"hidden_components",
Tag::List(vec![Tag::String("minecraft:lore".into())]),
)])),
);
assert!(!TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
}
#[test]
fn tooltip_display_controls_name_and_component_visibility() {
let mut stack = ItemStack::new("minecraft:diamond_sword", 1);
let display = TooltipDisplay::for_stack(&stack);
assert!(display.name_visible(false));
assert!(display.shows("minecraft:attribute_modifiers"));
stack.data_mut().set_other(
"minecraft:tooltip_display",
Some(Tag::compound([(
"hidden_components",
Tag::List(vec![Tag::String("minecraft:attribute_modifiers".into())]),
)])),
);
let display = TooltipDisplay::for_stack(&stack);
assert!(display.name_visible(false));
assert!(!display.shows("minecraft:attribute_modifiers"));
assert!(display.shows("minecraft:enchantments"));
stack.data_mut().set_other(
"minecraft:tooltip_display",
Some(Tag::compound([("hide_tooltip", Tag::Byte(1))])),
);
let display = TooltipDisplay::for_stack(&stack);
assert!(!display.name_visible(false));
assert!(display.name_visible(true));
assert!(!display.shows("minecraft:attribute_modifiers"));
}
#[test]
fn ominous_banner_keeps_its_default_hidden_pattern_tooltip() {
let mut banner = crate::mobs::raider::ominous_banner();
let display = TooltipDisplay::for_stack(&banner);
assert!(!display.shows("minecraft:banner_patterns"));
banner.data_mut().set_other(
"minecraft:tooltip_display",
Some(Tag::Compound(Default::default())),
);
let display = TooltipDisplay::for_stack(&banner);
assert!(display.shows("minecraft:banner_patterns"));
}
#[test]
fn ominous_banner_name_and_rarity_use_the_stack() {
let translate = |key: &str| match key {
"block.minecraft.ominous_banner" => Some("Ominous Banner".to_owned()),
"block.minecraft.white_banner" => Some("White Banner".to_owned()),
_ => None,
};
let mut banner = crate::mobs::raider::ominous_banner();
assert_eq!(name_line(&banner, &translate), "Ominous Banner");
assert_eq!(name_line(&banner, &|_| None), "Ominous Banner");
assert_eq!(rarity(&banner, "common"), "uncommon");
banner.data_mut().custom_name = Some("Captain's Standard".into());
assert_eq!(name_line(&banner, &translate), "Captain's Standard");
let mut custom_item = ItemStack::new("minecraft:diamond_sword", 1);
custom_item.data_mut().other.push((
"minecraft:item_name".into(),
Tag::String("Excalibur".into()),
));
assert_eq!(name_line(&custom_item, &translate), "Excalibur");
custom_item.data_mut().custom_name = Some("Custom Blade".into());
assert_eq!(name_line(&custom_item, &translate), "Custom Blade");
banner.enchant("minecraft:unbreaking", 1);
assert_eq!(rarity(&banner, "common"), "rare");
let plain = ItemStack::new("minecraft:white_banner", 1);
assert_eq!(name_line(&plain, &translate), "White Banner");
assert_eq!(rarity(&plain, "common"), "common");
}
#[test]
fn container_lines_list_five_stacks_then_how_many_more() {
let mut chest = ItemStack::new("minecraft:chest", 1);
for slot in [9u8, 1, 2, 3, 4, 5, 6] {
chest
.data_mut()
.container
.push((slot, ItemStack::new("minecraft:stone", u32::from(slot))));
}
let lang = |k: &str| {
Some(
match k {
"item.container.item_count" => "%s x %s",
"item.container.more_items" => "and %s more...",
_ => return None,
}
.to_owned(),
)
};
let name = |s: &ItemStack| s.item.trim_start_matches("minecraft:").to_owned();
assert_eq!(
container_lines(&chest, &name, &lang),
[
"1 x stone",
"2 x stone",
"3 x stone",
"4 x stone",
"5 x stone",
"and 2 more..."
]
);
assert!(container_lines(&ItemStack::new("minecraft:chest", 1), &name, &lang).is_empty());
}
#[test]
fn fireworks_use_loaded_tooltip_translations() {
let root = std::env::temp_dir().join(format!(
"horizon-firework-tooltip-language-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
let dir = root.join("assets/minecraft/lang");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("en_us.json"),
serde_json::to_vec(&serde_json::json!({
"item.minecraft.firework_rocket.flight_duration": "Flight Duration: %s",
"item.minecraft.firework_rocket.multiple_stars": "%s x %s",
"item.minecraft.firework_star.shape.small_ball": "Small Ball",
"item.minecraft.firework_star.red": "Red",
"item.minecraft.firework_star.custom_color": "Custom",
"item.minecraft.firework_star.fade_to": "Fade to",
"item.minecraft.firework_star.trail": "Trail",
"item.minecraft.firework_star.flicker": "Twinkle",
}))
.unwrap(),
)
.unwrap();
let enchantments = crate::enchant::Enchantments::load(&root);
std::fs::remove_dir_all(&root).unwrap();
let translate = |key: &str| enchantments.translate(key).map(str::to_owned);
let mut rocket = ItemStack::new("minecraft:firework_rocket", 1);
assert_eq!(firework_lines(&rocket, &translate), ["Flight Duration: 1"]);
let explosion = crate::particles::FireworkExplosion {
colors: vec![0xB3312C],
fade: vec![0x123456],
trail: true,
twinkle: true,
..Default::default()
};
rocket.data_mut().fireworks = Some((3, vec![explosion.clone(), explosion.clone()]));
assert_eq!(
firework_lines(&rocket, &translate),
[
"Flight Duration: 3",
"2 x Small Ball",
" Red",
" Fade to Custom",
" Trail",
" Twinkle",
]
);
let mut star = ItemStack::new("minecraft:firework_star", 1);
star.data_mut().firework_explosion = Some(explosion);
assert_eq!(
firework_lines(&star, &translate),
["Small Ball", "Red", "Fade to Custom", "Trail", "Twinkle"]
);
}
#[test]
fn amounts_use_two_decimals_at_most() {
assert_eq!(format_amount(8.0), "8");
assert_eq!(format_amount(3.2000000953674316), "3.2");
assert_eq!(format_amount(2.8200000524520874), "2.82");
assert_eq!(format_amount(0.125), "0.12");
assert_eq!(format_amount(0.375), "0.38");
}
#[test]
fn equipment_attributes_use_player_totals_and_slot_group_colors() {
let modifiers = [
("attack_damage", "base_attack_damage", 6.0, "mainhand"),
("attack_speed", "base_attack_speed", -2.4, "mainhand"),
("armor", "armor", 3.0, "head"),
]
.map(|(attribute, id, amount, slot)| {
(
format!("minecraft:{attribute}"),
format!("minecraft:{id}"),
amount,
"add_value".to_owned(),
slot.to_owned(),
)
});
let root = std::env::temp_dir().join(format!(
"horizon-tooltip-language-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
let dir = root.join("assets/minecraft/lang");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("en_us.json"),
serde_json::to_vec(&serde_json::json!({
"item.modifiers.mainhand": "When in Main Hand:",
"item.modifiers.head": "When on Head:",
"attribute.modifier.equals.0": "%s %s",
"attribute.modifier.plus.0": "+%s %s",
"attribute.modifier.take.0": "-%s %s",
"attribute.name.attack_damage": "Attack Damage",
"attribute.name.attack_speed": "Attack Speed",
"attribute.name.armor": "Armor",
}))
.unwrap(),
)
.unwrap();
let enchantments = crate::enchant::Enchantments::load(&root);
std::fs::remove_dir_all(&root).unwrap();
let lang = |key: &str| enchantments.translate(key).map(str::to_owned);
let green = rgba(0, 170, 0, 255);
let gray = rgba(170, 170, 170, 255);
let blue = rgba(85, 85, 255, 255);
let red = rgba(255, 85, 85, 255);
assert_eq!(
attribute_lines(&modifiers, Some([1.0, 4.0]), &lang),
[
(String::new(), u32::MAX),
("When in Main Hand:".into(), gray),
(" 7 Attack Damage".into(), green),
(" 1.6 Attack Speed".into(), green),
(String::new(), u32::MAX),
("When on Head:".into(), gray),
("+3 Armor".into(), blue),
]
);
let recipe = attribute_lines(&modifiers, None, &lang);
assert_eq!(recipe[2], ("+6 Attack Damage".into(), blue));
assert_eq!(recipe[3], ("-2.4 Attack Speed".into(), red));
let custom_base = attribute_lines(&modifiers, Some([2.0, 6.0]), &lang);
assert_eq!(custom_base[2].0, " 8 Attack Damage");
assert_eq!(custom_base[3].0, " 3.6 Attack Speed");
assert_eq!(
modifier_line(&lang, "attack_damage", 0.0, 0, true).as_deref(),
Some("0 Attack Damage")
);
}
#[test]
#[ignore = "requires prepared items/language in HORIZON_TEST_RESOURCES"]
fn equipment_attributes_with_prepared_items_and_language() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let items = crate::items::Items::catalog(&root).unwrap();
let enchantments = crate::enchant::Enchantments::load(&root);
let translate = |key: &str| enchantments.translate(key).map(str::to_owned);
for (item, expected) in [
(
"minecraft:diamond_sword",
[
"",
"When in Main Hand:",
" 7 Attack Damage",
" 1.6 Attack Speed",
],
),
(
"minecraft:diamond_helmet",
["", "When on Head:", "+3 Armor", "+2 Armor Toughness"],
),
] {
let info = items.get(item).unwrap();
let lines = attribute_lines(&info.modifiers, Some([1.0, 4.0]), &translate);
assert_eq!(
lines
.iter()
.map(|(text, _)| text.as_str())
.collect::<Vec<_>>(),
expected,
"{item}"
);
}
}
#[test]
fn modifier_lines_follow_the_sign_and_operation() {
let lang = |k: &str| {
Some(
match k {
"attribute.modifier.plus.2" => "+%s%% %s",
"attribute.modifier.take.2" => "-%s%% %s",
"attribute.name.movement_speed" => "Speed",
_ => return None,
}
.to_owned(),
)
};
assert_eq!(
modifier_line(&lang, "movement_speed", 0.2, 2, false).as_deref(),
Some("+20% Speed")
);
assert_eq!(
modifier_line(&lang, "movement_speed", -0.6, 2, false).as_deref(),
Some("-60% Speed")
);
assert_eq!(modifier_line(&lang, "movement_speed", 0.0, 2, false), None);
}
}