Merge remote-tracking branch 'origin/main' into fix/7080-armor-stand-world-border

This commit is contained in:
Akirabane committed 2026-11-02 14:40:25 +01:00
commit c769fc342c
24 files changed
+859 -122

No files matched your search

+3 -2
View File
@@ -96,8 +96,9 @@ jobs:
run: |
cargo +stable test --workspace --lib --bins --tests
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
# recompiling the workspace with a different dependency graph.
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
@@ -688,4 +688,15 @@ mod tests {
let (v, _) = parse_snbt("{n:1.5}").unwrap();
assert_eq!(v["n"], Value::from(1.5));
}
#[test]
fn name_text_preserves_legacy_and_decodes_components() {
let legacy = Tag::String("§6Golden Name".into());
let text = name_text(&legacy);
assert_eq!(text.plain(), "Golden Name");
let comp = Tag::compound([("text", Tag::String("Custom Name".into()))]);
let text = name_text(&comp);
assert_eq!(text.plain(), "Custom Name");
}
}
+192 -80
View File
@@ -62,18 +62,65 @@ pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
}
#[derive(Clone)]
pub struct CellIter {
radius: i32,
ring: i32,
x: i32,
pending_z: Option<i32>,
}
impl CellIter {
pub fn new(radius: i32) -> Self {
Self {
radius,
ring: 0,
x: 0,
pending_z: None,
}
}
}
impl Iterator for CellIter {
type Item = (i32, i32);
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if let Some(z) = self.pending_z.take() {
let x = self.x;
self.x += 1;
return Some((x, z));
}
let r2 = self.radius * self.radius;
while self.ring <= 2 * self.radius {
let min_abs = self.ring - self.radius;
while self.x <= self.ring {
if min_abs > 0 && self.x > -min_abs && self.x < min_abs {
self.x = min_abs;
}
let x = self.x;
let z = self.ring - x.abs();
if z <= self.radius && x * x + z * z <= r2 {
if z != 0 {
self.pending_z = Some(z);
return Some((x, -z));
} else {
self.x += 1;
return Some((x, 0));
}
}
self.x += 1;
}
self.ring += 1;
self.x = -self.ring;
}
None
}
}
/// CloudRenderer builds cells in expanding Manhattan rings, nearest first.
pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
(0..=2 * radius).flat_map(move |ring| {
(-ring..=ring).flat_map(move |x| {
let z = ring - x.abs();
let visible = z <= radius && x * x + z * z <= radius * radius;
(visible && z != 0)
.then_some((x, -z))
.into_iter()
.chain(visible.then_some((x, z)))
})
})
pub fn cells(radius: i32) -> CellIter {
CellIter::new(radius)
}
/// Per-frame cloud inputs. Resources are immutable for the renderer's lifetime:
@@ -150,9 +197,121 @@ impl Input<'_> {
#[derive(Default)]
pub(crate) struct Mesh {
key: Option<MeshKey>,
cells: Vec<(i32, i32)>,
cached_radius: Option<i32>,
pub vertices: Vec<crate::mesher::Vertex>,
}
#[inline(always)]
fn push_quad(
vertices: &mut Vec<crate::mesher::Vertex>,
p: [[f32; 3]; 4],
shade: u32,
normal: [i8; 4],
uv: [f32; 2],
) {
for pos in p {
vertices.push(crate::mesher::Vertex {
normal,
pos,
uv,
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
}
#[inline(always)]
fn emit_face(
vertices: &mut Vec<crate::mesher::Vertex>,
direction: usize,
inside: bool,
x0: f32,
z0: f32,
uv: [f32; 2],
) {
let x1 = x0 + 12.0;
let z1 = z0 + 12.0;
const Y0: f32 = 0.0;
const Y1: f32 = 4.0;
let (p, shade, normal) = match direction {
0 => (
[[x1, Y0, z0], [x1, Y0, z1], [x0, Y0, z1], [x0, Y0, z0]],
1,
[0, -127, 0, 0],
),
1 => (
[[x0, Y1, z0], [x0, Y1, z1], [x1, Y1, z1], [x1, Y1, z0]],
0,
[0, 127, 0, 0],
),
2 => (
[[x0, Y0, z0], [x0, Y1, z0], [x1, Y1, z0], [x1, Y0, z0]],
2,
[0, 0, -127, 0],
),
3 => (
[[x1, Y0, z1], [x1, Y1, z1], [x0, Y1, z1], [x0, Y0, z1]],
2,
[0, 0, 127, 0],
),
4 => (
[[x0, Y0, z1], [x0, Y1, z1], [x0, Y1, z0], [x0, Y0, z0]],
3,
[-127, 0, 0, 0],
),
_ => (
[[x1, Y0, z0], [x1, Y1, z0], [x1, Y1, z1], [x1, Y0, z1]],
3,
[127, 0, 0, 0],
),
};
if inside {
push_quad(
vertices,
[p[3], p[2], p[1], p[0]],
shade,
normal.map(|c| -c),
uv,
);
} else {
push_quad(vertices, p, shade, normal, uv);
}
}
#[inline(always)]
fn emit_faces(
vertices: &mut Vec<crate::mesher::Vertex>,
mask: u16,
x0: f32,
z0: f32,
uv: [f32; 2],
) {
for d in [1, 0, 2, 3, 4, 5] {
if mask & (1 << d) != 0 {
emit_face(vertices, d, false, x0, z0, uv);
}
}
if mask >> 6 != 0 {
for d in 0..6 {
if mask & (1 << (d + 6)) != 0 {
emit_face(vertices, d, true, x0, z0, uv);
}
}
}
}
#[inline(always)]
fn emit_flat_cell(vertices: &mut Vec<crate::mesher::Vertex>, x0: f32, z0: f32, uv: [f32; 2]) {
let x1 = x0 + 12.0;
let z1 = z0 + 12.0;
let p = [[x1, 0.0, z0], [x1, 0.0, z1], [x0, 0.0, z1], [x0, 0.0, z0]];
push_quad(vertices, p, 0, [0; 4], uv);
push_quad(vertices, [p[3], p[2], p[1], p[0]], 0, [0; 4], uv);
}
impl Mesh {
/// Returns true only when vertices must be uploaded. Sub-cell motion and
/// color changes update only the uniform, as in CloudRenderer.prepare.
@@ -161,80 +320,39 @@ impl Mesh {
if self.key.as_ref() == Some(&key) {
return (false, uniform);
}
if self.cached_radius != Some(key.radius) {
self.cells = self::cells(key.radius).collect();
self.cached_radius = Some(key.radius);
}
self.vertices.clear();
let (w, h, cells) = input.texture;
let (w, h) = (i64::from(*w), i64::from(*h));
let (cell_x, cell_z, radius) = (key.cell_x, key.cell_z, key.radius);
let (w, h, tex_cells) = input.texture;
let (cell_x, cell_z) = (key.cell_x, key.cell_z);
let bottom = uniform.offset[1];
let uv = input.uv;
let filled = |cx: i64, cz: i64| cells[(cz.rem_euclid(h) * w + cx.rem_euclid(w)) as usize];
for (dx, dz) in self::cells(radius) {
let (dx, dz) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx, cell_z + dz);
let (w_i64, h_i64) = (i64::from(*w), i64::from(*h));
let filled = |cx: i64, cz: i64| {
tex_cells[(cz.rem_euclid(h_i64) * w_i64 + cx.rem_euclid(w_i64)) as usize]
};
for &(dx, dz) in &self.cells {
let (dx_i64, dz_i64) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx_i64, cell_z + dz_i64);
if !filled(cx, cz) {
continue;
}
let x0 = dx as f32 * 12.0;
let z0 = dz as f32 * 12.0;
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
// The face's normal is for shader packs (`gl_Normal`); the game's clouds shade by
// `shade`.
let mut quad = |p: [[f32; 3]; 4], shade: u32, normal: [i8; 4]| {
for v in p {
// Overlay 4: clouds (unlit, faded by the cloud fog end).
self.vertices.push(crate::mesher::Vertex {
normal,
pos: v,
uv,
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
};
if input.fast {
for p in flat_cell(x0, z0, 0.0) {
quad(p, 0, [0; 4]);
}
emit_flat_cell(&mut self.vertices, x0, z0, uv);
continue;
}
let quads = [
(
[[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]],
1,
[0, -127, 0, 0],
),
(
[[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]],
0,
[0, 127, 0, 0],
),
(
[[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]],
2,
[0, 0, -127, 0],
),
(
[[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]],
2,
[0, 0, 127, 0],
),
(
[[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]],
3,
[-127, 0, 0, 0],
),
(
[[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
3,
[127, 0, 0, 0],
),
];
let mask = face_mask(
dx as i32,
dz as i32,
dx,
dz,
bottom,
[
!filled(cx, cz - 1),
@@ -243,15 +361,9 @@ impl Mesh {
!filled(cx + 1, cz),
],
);
for (direction, inside) in faces(mask) {
let (p, shade, n) = quads[usize::from(direction)];
if inside {
quad([p[3], p[2], p[1], p[0]], shade, n.map(|c| -c));
} else {
quad(p, shade, n);
}
}
emit_faces(&mut self.vertices, mask, x0, z0, uv);
}
self.key = Some(key);
(true, uniform)
}
+23
View File
@@ -1425,6 +1425,29 @@ mod tests {
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
}
}
/// A `recipe_crafted` criterion names `minecraft:x`; the craft hands
/// the recipe book and the trigger the same whole id (`award_crafted`
/// qualifies). The bare name the crafting copy uses never matched —
/// the second half of #7186.
#[test]
fn a_recipe_crafted_criterion_matches_the_whole_id_only() {
let w = Flat;
let criterion = serde_json::json!({"recipes": "minecraft:folder_recipe"});
assert!(
!id_matches(&criterion["recipes"], "recipe", "folder_recipe", &w),
"the crafting copy's bare name never matched the criterion"
);
assert!(
id_matches(
&criterion["recipes"],
"recipe",
&crate::crafting::qualified("folder_recipe"),
&w
),
"the whole id award_crafted passes matches"
);
}
/// `LockCode.canUnlock` for a chest with
/// `lock:{items:"minecraft:gold_ingot"}`: a gold ingot opens it, an empty
/// hand or another item does not, and a spectator always does.
+12
View File
@@ -1305,6 +1305,18 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
augment(i, ingredients, stacks, &mut owner, &mut seen)
})
}
/// A resource id with its default namespace filled in — the whole id the
/// book, the network copy and the advancements' JSON know a recipe by,
/// whatever the caller passes. `sources` names a `minecraft:` recipe bare
/// on purpose (vanilla's own ids); this puts the namespace back.
pub fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
impl Recipes {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
let sources = Self::sources(root, overlay)?;
+8 -2
View File
@@ -8773,9 +8773,15 @@ impl Game<'_> {
/// (special ones never do).
fn award_crafted(&mut self, crafts: &[(String, Vec<Option<ItemStack>>)]) {
for (recipe, ingredients) in crafts {
self.trigger_recipe_crafted(recipe, ingredients);
// The whole id: the advancements' `recipe_crafted` criteria name
// `minecraft:x`, and the crafting copy names the recipe bare.
let recipe = horizon_client::crafting::qualified(recipe);
self.trigger_recipe_crafted(&recipe, ingredients);
}
let recipes: Vec<String> = crafts.iter().map(|(r, _)| r.clone()).collect();
let recipes: Vec<String> = crafts
.iter()
.map(|(r, _)| horizon_client::crafting::qualified(r))
.collect();
let index = &world_stations!(self).recipe_index;
let (_, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
self.recipe_unlocked(&unlocked);
+28 -3
View File
@@ -2175,7 +2175,8 @@ impl Game<'_> {
if self.ui.difficulty == 0 && t.category == "monster" {
return Err(err("commands.summon.failed.peaceful", &[]));
}
self.spawn_mob_spawned(&short, pos);
self.spawn_mob_spawned(&short, pos)
.ok_or_else(|| err("commands.summon.failed.uuid", &[]))?;
name = Text::translate(&entity_name_key(&kind), &[]);
} else {
match short.as_str() {
@@ -2250,7 +2251,11 @@ impl Game<'_> {
let empty = horizon_world::nbt::Tag::Compound(Default::default());
cart.spawner = Some(Box::new(Spawner::load(&empty)));
}
let id = cart.id;
self.carts.push(cart);
if !self.native_joins_flush(false, id) {
return Err(err("commands.summon.failed.uuid", &[]));
}
name = Text::translate(&format!("entity.minecraft.{k}"), &[]);
}
// SummonCommand.createEntity: any other type from an empty compound.
@@ -2936,6 +2941,27 @@ impl Game<'_> {
}
true
}
/// `GameRules.set` as the `/gamerule` command and the management protocol call it: the rule changes, the
/// world follows it, and a dedicated server tells the event stream (`MinecraftServer.onGameRuleChanged`
/// tells the management protocol's clients).
pub(super) fn set_game_rule(&mut self, rule: &gamerules::Rule, value: Value) {
self.ui.rules.set(rule.name, value);
self.sync_rules();
if self.options.dedicated.is_some() {
let (value_type, json) = match value {
Value::Bool(b) => ("boolean", serde_json::json!(b)),
Value::Int(i) => ("integer", serde_json::json!(i)),
};
super::dedicated::events::record_server(
"gamerule",
serde_json::json!({
"key": format!("minecraft:{}", rule.name),
"valueType": value_type,
"value": json,
}),
);
}
}
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
let Some(name) = ctx.literals.get(1).cloned() else {
return Ok(0);
@@ -2958,8 +2984,7 @@ impl Game<'_> {
if current == value {
return Err(err("commands.gamerule.not_set", &[&rule.name, &value]));
}
self.ui.rules.set(rule.name, value);
self.sync_rules();
self.set_game_rule(rule, value);
self.feedback(Text::tr("commands.gamerule.set", &[&rule.name, &value]));
Ok(match value {
Value::Bool(b) => i32::from(b),
@@ -431,6 +431,9 @@ impl Game<'_> {
let id = mob.id;
self.mobs.push(mob);
self.native_mobs_joined(self.mobs.len() - 1, false);
if !self.mobs.iter().any(|m| m.id == id) {
return None;
}
self.enchant_spawn_equipment(id);
self.halloween_spawn_head(id);
if !persistent && !scripted {
@@ -7,6 +7,7 @@ use super::{GAME_MODES, ServerState, auto_save, log, set_auto_save};
use crate::game::Game;
use horizon_client::{
command::vanilla::DIFFICULTIES,
gamerules::{self, Value as GameRuleValue},
server::{
profiles,
user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
@@ -29,6 +30,15 @@ struct Op {
bypass: Option<bool>,
}
/// A `GameRuleUpdate` as `TYPED_CODEC` writes it: the rule's id, its type and its value.
fn typed_rule(rule: &gamerules::Rule, value: GameRuleValue) -> Value {
let (kind, value) = match value {
GameRuleValue::Bool(b) => ("boolean", json!(b)),
GameRuleValue::Int(i) => ("integer", json!(i)),
};
json!({ "key": format!("minecraft:{}", rule.name), "type": kind, "value": value })
}
/// `NameAndId`'s record `toString`, as vanilla's log lines show it.
fn shown(user: &NameAndId) -> String {
format!(
@@ -49,6 +59,48 @@ impl Game<'_> {
let entries = body["entries"].as_array().cloned().unwrap_or_default();
let lists = state.lists.clone();
match body["method"].as_str().unwrap_or_default() {
// `GameRulesService.get`: every rule the server has, with its type and current value.
"gamerules" => {
let rules = &self.ui.rules;
let all: Vec<Value> = rules
.available_rules()
.map(|r| typed_rule(r, rules.get(r.name).unwrap_or(r.default)))
.collect();
return json!({ "value": all });
}
// `GameRulesService.update`: the rule is set only when its value changes, and logged.
"gamerules/update" => {
let update = &body["value"];
let Some(rule) = update["key"]
.as_str()
.and_then(gamerules::rule)
.filter(|r| self.ui.rules.get(r.name).is_some())
else {
return json!({ "invalid": format!("Unknown game rule: {}", update["key"]) });
};
let wanted = &update["value"];
let value = match (rule.default, wanted) {
(GameRuleValue::Bool(_), Value::Bool(b)) => Some(GameRuleValue::Bool(*b)),
(GameRuleValue::Int(_), n) => n
.as_i64()
.and_then(|n| i32::try_from(n).ok())
.filter(|n| (rule.min..=rule.max).contains(n))
.map(GameRuleValue::Int),
_ => None,
};
let Some(value) = value else {
return json!({ "invalid": format!("Invalid value {wanted} for game rule {}", rule.name) });
};
let current = self.ui.rules.get(rule.name).unwrap_or(rule.default);
if current != value {
rpc(format!(
"Game rule '{}' updated from '{current}' to '{value}'",
rule.name
));
self.set_game_rule(rule, value);
}
return json!({ "value": typed_rule(rule, value) });
}
"serversettings/autosave" => {
return json!({ "value": auto_save(state) });
}
@@ -491,3 +543,22 @@ fn management_ip_ban(entry: &Value, ip: String) -> IpBan {
reason,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_game_rule_is_written_as_vanillas_typed_codec_does() {
let pvp = gamerules::rule("pvp").unwrap();
assert_eq!(
typed_rule(pvp, GameRuleValue::Bool(false)),
json!({ "key": "minecraft:pvp", "type": "boolean", "value": false })
);
let tick = gamerules::rule("minecraft:random_tick_speed").unwrap();
assert_eq!(
typed_rule(tick, GameRuleValue::Int(3)),
json!({ "key": "minecraft:random_tick_speed", "type": "integer", "value": 3 })
);
}
}
+31 -4
View File
@@ -273,10 +273,20 @@ fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
const BUILD_INFO: &str = "native-mods.json";
/// Whether the launcher built this game with native mods (`built`: its [`BUILD_INFO`]).
fn built_with_mods(built: &str) -> bool {
/// The native mods the launcher built this game with, `id version, id version`, for the crash
/// report (`built`: its [`BUILD_INFO`]). None for a release or Nightly, which have no mods.
fn native_mods_detail(built: &str) -> Option<String> {
let info: serde_json::Value = serde_json::from_str(built).unwrap_or_default();
info["mods"].as_array().is_some_and(|mods| !mods.is_empty())
let list = info["mods"]
.as_array()?
.iter()
.map(|m| {
let text = |key: &str| m[key].as_str().unwrap_or("?").to_owned();
format!("{} {}", text("id"), text("version"))
})
.collect::<Vec<_>>()
.join(", ");
(!list.is_empty()).then_some(list)
}
/// A row for each native mod the launcher installed in `dir` (`<id>.hnmod`) that this game wasn't
@@ -366,8 +376,13 @@ impl Game<'_> {
.ok()
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
.unwrap_or_default();
let built_mods = native_mods_detail(&built);
let built_with_mods = built_mods.is_some();
if let Some(list) = built_mods {
horizon_client::crash::set_detail("Native Mods", list);
}
horizon_client::gfx::native_render::set_mods_built_in(
built_with_mods(&built)
built_with_mods
|| self
.settings
.mods
@@ -428,6 +443,18 @@ mod tests {
zip.finish().unwrap();
}
#[test]
fn the_crash_report_lists_the_builds_native_mods() {
let built = r#"{"mods":[{"id":"machine_sample","version":"0.1.0","sha256":null},{"id":"pipes","version":"2.0"}]}"#;
assert_eq!(
native_mods_detail(built).as_deref(),
Some("machine_sample 0.1.0, pipes 2.0")
);
// A release has no file; a build without mods has an empty list.
assert_eq!(native_mods_detail(""), None);
assert_eq!(native_mods_detail(r#"{"mods":[]}"#), None);
}
#[test]
fn native_mods_installed_but_not_built_get_a_row_saying_so() {
let dir = std::env::temp_dir().join(format!("horizon-not-built-{}", std::process::id()));
+47
View File
@@ -1102,4 +1102,51 @@ mod tests {
other(&item, "minecraft:custom_model_data")
);
}
#[test]
fn rarity_survives_lan_sync_and_enchantment_upgrade() {
use horizon_protocol::buf::{Reader, Writer};
use horizon_protocol::item::Slot;
use horizon_protocol::registry::Ids;
let mut components = vec![String::new(); component::RARITY as usize + 1];
components[component::RARITY as usize] = "minecraft:rarity".into();
let builtin = Builtin {
registries: [
(
"minecraft:item".into(),
Ids::from_names(vec!["minecraft:air".into(), "minecraft:stick".into()]),
),
(
"minecraft:data_component_type".into(),
Ids::from_names(components),
),
]
.into(),
};
let dynamic = Dynamic::default();
let mut item = ItemStack::new("minecraft:stick", 1);
item.data_mut()
.set_other("minecraft:rarity", Some(Tag::String("epic".into())));
item.enchant("minecraft:aqua_affinity", 1);
let wire = crate::lan::guest::stack_to_network(
|name| builtin.get("minecraft:item").unwrap().id(name).unwrap(),
|_, _| None,
&Some(item.clone()),
);
let mut w = Writer::new();
Slot::write(&wire, &mut w);
let received = Slot::read(&mut Reader::new(&w.data)).unwrap().unwrap();
assert_eq!(
received.component(component::RARITY),
Some(&Component::Int(3))
);
let back = from_network(&received, &builtin, &dynamic);
assert_eq!(
back.data().and_then(|d| d.other("minecraft:rarity")),
Some(&Tag::String("epic".into()))
);
assert_eq!(crate::item_tooltip::rarity(&back, "common"), "epic");
}
}
+39 -5
View File
@@ -1053,7 +1053,7 @@ pub fn 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 {
let Some(raw) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
return Vec::new();
};
let base = crate::text::Style {
@@ -1061,10 +1061,19 @@ pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
italic: Some(true),
..Default::default()
};
entries
.iter()
.map(|entry| {
let mut text = crate::entity_nbt::name_text(entry).resolve_legacy();
let lines: Vec<crate::text::Text> = match raw {
Tag::List(entries) => entries
.iter()
.map(|entry| crate::entity_nbt::name_text(entry).resolve_legacy())
.collect(),
Tag::Compound(_) | Tag::String(_) => {
vec![crate::entity_nbt::name_text(raw).resolve_legacy()]
}
_ => Vec::new(),
};
lines
.into_iter()
.map(|mut text| {
for span in &mut text.spans {
span.style.inherit(&base);
}
@@ -1360,6 +1369,31 @@ mod tests {
assert_eq!(rarity(&sword, "common"), "rare");
}
#[test]
fn lore_texts_handles_compound_lists() {
// Direct command components from issue #6405
let cmd = r#"[custom_name=[{"text":"Simple Custom Name","italic":false}],lore=[[{"text":"Simple Custom Lore","italic":false}]],rarity=epic,enchantments={aqua_affinity:1},unbreakable={}]"#;
let comp = horizon_items::item_data::command_components(cmd).unwrap();
let (damage, extra) = horizon_items::item_data::decode_components(Some(&comp));
let stack = ItemStack {
item: "minecraft:stick".into(),
count: 1,
damage,
extra,
};
assert_eq!(rarity(&stack, "common"), "epic");
let name = styled_name(&stack, || "fallback".into());
assert_eq!(name.plain(), "Simple Custom Name");
assert_eq!(name.spans[0].style.italic, Some(false));
let lore = lore_texts(&stack);
assert_eq!(lore.len(), 1);
assert_eq!(lore[0].plain(), "Simple Custom Lore");
assert_eq!(lore[0].spans[0].style.italic, Some(false));
assert_eq!(lore[0].spans[0].style.color, Some(crate::text::DARK_PURPLE));
assert!(TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
}
#[test]
fn name_line_translates_items_of_any_namespace() {
let translate = |key: &str| match key {
@@ -716,6 +716,78 @@ mod tests {
/// A recipe whose ingredient kind no loader knows stays out of the book:
/// the server does not load it, and an empty ingredient slot in its
/// place would mislead the client.
/// The shared probe: a recipe the player crafts is learned by the
/// name the crafting copy hands out, and rejoining sends what the
/// book knows.
fn crafted_recipe_reaches_the_book(name: &str, pack: bool) {
let shaped = r#"{"type":"minecraft:crafting_shaped","pattern":["X"],
"key":{"X":"minecraft:raw_copper"},
"result":{"id":"minecraft:copper_ingot"}}"#;
let root = if pack {
resources(name, &[])
} else {
resources(name, &[(name, shaped)])
};
let packs = pack.then(|| {
vec![(
format!("recipe/minecraft:{name}"),
vec![shaped.as_bytes().to_vec()],
)]
});
let overlay = match &packs {
Some(p) => horizon_world::loot::PackOverlay { files: p },
None => horizon_world::loot::PackOverlay::NONE,
};
let sync = SyncData::load(&root, &overlay).unwrap();
// What the craft hands the book: find_with over the crafting
// copy, the way award_crafted calls it.
let items = crate::items::Items::catalog(&root, &overlay).unwrap();
let recipes = crate::crafting::Recipes::load(&root, &items, &overlay).unwrap();
let grid = vec![Some(horizon_items::items::ItemStack {
item: "minecraft:raw_copper".into(),
count: 1,
damage: 0,
extra: None,
})];
let (found, _) = recipes
.find_with(&grid, 1, &|_| None)
.unwrap_or_else(|| panic!("{name}: the recipe matches a grid"));
assert_eq!(
found, name,
"{name}: the crafting copy names it bare, folder and pack alike"
);
// The book learns what the craft awards; rejoining sends it.
let mut book = crate::recipe_book::Settings::default();
let (_, unlocked) = book.add_recipes(&sync.advancements.displays, &[found.to_owned()]);
assert_eq!(
unlocked,
[format!("minecraft:{name}")],
"{name}: the book knows it under its whole id"
);
let bytes = sync.advancements.initial_recipe_book(&book, &sync);
let mut r = Reader::new(&bytes);
let add =
<horizon_protocol::packets::recipes::RecipeBookAdd as horizon_protocol::buf::Codec<
horizon_protocol::packets::recipes::RecipeBookAdd,
>>::read(&mut r)
.unwrap();
assert!(
!add.entries.is_empty(),
"{name}: the crafted recipe reaches the book after rejoining"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_folder_recipe_unlocked_by_crafting_shows_in_the_book() {
crafted_recipe_reaches_the_book("folder_recipe", false);
}
#[test]
fn a_pack_recipe_unlocked_by_crafting_shows_in_the_book() {
crafted_recipe_reaches_the_book("pack_recipe", true);
}
#[test]
fn a_recipe_with_an_unknown_ingredient_kind_stays_out_of_the_book() {
let root = resources(
+1 -8
View File
@@ -82,14 +82,7 @@ pub struct SyncData {
pub advancements: super::advancements::ServerAdvancements,
}
/// A resource id with its default namespace filled in.
fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
use crate::crafting::qualified;
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted;
/// a file that can't be read or doesn't parse is left out. Through `json_folder`: from the
+66 -6
View File
@@ -1106,13 +1106,55 @@ fn network_stack(
}
// Lore lines as loaded or as a server mod wrote them (a list of text
// components), so a server menu's items can say more than their name.
if let Some(horizon_world::nbt::Tag::List(lines)) =
s.data().and_then(|d| d.other("minecraft:lore"))
if let Some(lore_tag) = s.data().and_then(|d| d.other("minecraft:lore")) {
let lines = match lore_tag {
horizon_world::nbt::Tag::List(lines) => Some(lines.clone()),
// A string is one literal line, as ComponentSerialization decodes a string tag.
horizon_world::nbt::Tag::Compound(_) | horizon_world::nbt::Tag::String(_) => {
Some(vec![lore_tag.clone()])
}
_ => None,
};
if let Some(lines) = lines {
stack.added.push((
horizon_protocol::item::component::LORE,
horizon_protocol::item::Component::Lore(lines),
));
}
}
if s.data()
.is_some_and(|d| d.other("!minecraft:lore").is_some())
{
stack.added.push((
horizon_protocol::item::component::LORE,
horizon_protocol::item::Component::Lore(lines.clone()),
));
stack.removed.push(horizon_protocol::item::component::LORE);
}
// The stack's custom rarity ("common", "uncommon", "rare", "epic" or 0..=3).
if let Some(r) = s.data().and_then(|d| d.other("minecraft:rarity")) {
let index = match r {
horizon_world::nbt::Tag::String(s) => match &*s.to_string_lossy() {
"common" => Some(0),
"uncommon" => Some(1),
"rare" => Some(2),
"epic" => Some(3),
_ => None,
},
horizon_world::nbt::Tag::Byte(b) if (0..=3).contains(b) => Some(*b as i32),
horizon_world::nbt::Tag::Short(s) if (0..=3).contains(s) => Some(*s as i32),
horizon_world::nbt::Tag::Int(i) if (0..=3).contains(i) => Some(*i),
_ => None,
};
if let Some(index) = index {
stack.added.push((
horizon_protocol::item::component::RARITY,
horizon_protocol::item::Component::Int(index),
));
}
}
if s.data()
.is_some_and(|d| d.other("!minecraft:rarity").is_some())
{
stack
.removed
.push(horizon_protocol::item::component::RARITY);
}
// The component as loaded, so a translatable name stays one.
if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
@@ -1241,6 +1283,24 @@ mod tests {
);
}
#[test]
fn rarity_reaches_the_client() {
let mut item = ItemStack::new("minecraft:stick", 1);
item.data_mut()
.set_other("minecraft:rarity", Some(Tag::String("epic".into())));
let sent = stack_to_network(|_| 7, |_, _| None, &Some(item));
let mut writer = Writer::new();
horizon_protocol::item::Slot::write(&sent, &mut writer);
let mut reader = Reader::new(&writer.data);
let received = horizon_protocol::item::Slot::read(&mut reader)
.unwrap()
.unwrap();
assert_eq!(
received.component(component::RARITY),
Some(&Component::Int(3))
);
}
#[test]
fn styled_custom_name_survives_inventory_sync() {
let name = Tag::compound([
+47
View File
@@ -271,6 +271,8 @@ pub enum ArmPose {
ThrowTrident,
/// Playing a goat horn.
TootHorn,
/// Looking through a spyglass (`HumanoidModel.ArmPose.SPYGLASS`).
Spyglass,
/// A spear held out (`HumanoidModel.ArmPose.SPEAR`), at rest or charging.
Spear,
/// A brush being used (`HumanoidModel.ArmPose.BRUSH`).
@@ -400,6 +402,8 @@ pub(super) fn crossbow_charge(p: &mut Poses, max: f32, ticks: f32, in_right: boo
let x = lerp(alpha, p.get(pull)[3], -PI / 2.0);
p.xrot(pull, x);
}
/// `HumanoidModel.SPYGLASS_ARM_ROT_X`.
const SPYGLASS_ARM_ROT_X: f32 = 1.919_862_2;
/// `HumanoidModel.poseRightArm` / `poseLeftArm`.
fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
let arm = if right { "right_arm" } else { "left_arm" };
@@ -436,6 +440,11 @@ fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
p.xrot(arm, x * 0.5 - PI);
p.yrot(arm, 0.0);
}
ArmPose::Spyglass => {
let crouch = if h.crouching { 0.261_799_4 } else { 0.0 };
p.xrot(arm, (hx - SPYGLASS_ARM_ROT_X - crouch).clamp(-2.4, 3.3));
p.yrot(arm, hy + if right { -0.261_799_4 } else { 0.261_799_4 });
}
ArmPose::TootHorn => {
p.xrot(arm, hx.clamp(-1.2, 1.2) - 1.483_529_8);
p.yrot(arm, hy + if right { -FRAC_PI_6 } else { FRAC_PI_6 });
@@ -818,6 +827,7 @@ pub fn setup(
Some(_) if using_main && m.extra.blocking => ArmPose::Block,
Some("minecraft:bow") if using_main => ArmPose::Bow,
Some("minecraft:trident") if using_main => ArmPose::ThrowTrident,
Some("minecraft:spyglass") if using_main => ArmPose::Spyglass,
Some("minecraft:goat_horn") if using_main => ArmPose::TootHorn,
Some("minecraft:crossbow") if using_main && !m.extra.charged => {
ArmPose::CrossbowCharge
@@ -838,6 +848,8 @@ pub fn setup(
ArmPose::Spear
} else if using_off && m.extra.blocking {
ArmPose::Block
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:spyglass") {
ArmPose::Spyglass
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:goat_horn") {
ArmPose::TootHorn
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:brush") {
@@ -1067,6 +1079,41 @@ mod flight_pose_tests {
}
}
}
/// HumanoidModel.poseRightArm / poseLeftArm's SPYGLASS case: pitch, yaw,
/// crouching, and the arm's pitch.
#[test]
fn a_spyglass_raises_the_arm_with_the_head_in_both_hands() {
for right in [false, true] {
for (pitch, yaw, crouching, expected_pitch) in [
(0.0, 0.0, false, -1.919_862_2),
(0.5, 0.3, false, -1.419_862_2),
(0.0, 0.0, true, -2.181_661_6),
// Looking up: limited to -2.4.
(-1.5, 0.0, false, -2.4),
] {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
};
let arm = if right { "right_arm" } else { "left_arm" };
p.xrot("head", pitch);
p.yrot("head", yaw);
let mut h = Hum::new(ArmPose::Empty, ArmPose::Empty);
h.crouching = crouching;
pose_arm(&mut p, ArmPose::Spyglass, right, &h);
let pose = *p.get(arm);
assert!((pose[3] - expected_pitch).abs() < 1e-6, "{pose:?}");
let side = if right { -0.261_799_4 } else { 0.261_799_4 };
assert!((pose[4] - (yaw + side)).abs() < 1e-6, "{pose:?}");
assert_eq!(p.get(if right { "left_arm" } else { "right_arm" })[3], 0.0);
}
}
}
/// EndermanModel.setupAnim (#4565): humanoid_pub forwards isPassenger so the
/// riding leg y/z survive; only xRot is halved and clamped to ±0.4 after.
#[test]
+29 -6
View File
@@ -835,11 +835,15 @@ impl Settings {
let mut displays = 0;
let mut added = Vec::new();
for id in ids {
if !self.recipes.contains(id) && !index.special(id) {
// The whole id: the crafting copy names a `minecraft:` recipe
// bare, and every reader of the book (the network copy, the
// advancements' JSON, the saved file) knows it qualified.
let id = crate::crafting::qualified(id);
if !self.recipes.contains(&id) && !index.special(&id) {
self.recipes.insert(id.clone());
self.to_be_displayed.insert(id.clone());
displays += index.displays(id);
added.push(id.clone());
displays += index.displays(&id);
added.push(id);
}
}
(displays, added)
@@ -879,9 +883,10 @@ impl Settings {
pub fn remove_recipes(&mut self, index: &RecipeIndex, ids: &[String]) -> Vec<i32> {
let mut displays = Vec::new();
for id in ids {
if self.recipes.remove(id) {
self.to_be_displayed.remove(id);
displays.extend(index.display_ids(id));
let id = crate::crafting::qualified(id);
if self.recipes.remove(&id) {
self.to_be_displayed.remove(&id);
displays.extend(index.display_ids(&id));
}
}
displays
@@ -959,6 +964,24 @@ impl Settings {
#[cfg(test)]
mod tests {
/// A book saved before #7186's fix holds bare names: reading fills the
/// namespace in, so those recipes show in the book too.
#[test]
fn a_book_saved_with_bare_names_reads_qualified() {
use horizon_world::nbt::{Compound, Tag};
let list = || Tag::List(vec![Tag::String("folder_recipe".into())]);
let mut compound = Compound::new();
compound.insert("recipes".into(), list());
compound.insert("toBeDisplayed".into(), list());
let tag = Tag::Compound(compound);
let read = Settings::read_valid(Some(&tag), &|_| true);
assert_eq!(
read.recipes,
["minecraft:folder_recipe".to_owned()].into_iter().collect()
);
assert!(read.to_be_displayed.contains("minecraft:folder_recipe"));
}
use super::*;
fn ing(items: &[&str]) -> Ingredient {
+8 -1
View File
@@ -797,7 +797,14 @@ pub fn apply_operation(op: &str, a: i32, b: i32) -> CmdResult<(i32, i32)> {
if b == 0 {
return Err(div0());
}
(a.wrapping_rem(b).wrapping_add(b).wrapping_rem(b), b)
let remainder = a.wrapping_rem(b);
// Floor remainder has the divisor's sign; only correct opposite signs.
let remainder = if remainder != 0 && ((remainder < 0) != (b < 0)) {
remainder + b
} else {
remainder
};
(remainder, b)
}
"<" => (a.min(b), b),
">" => (a.max(b), b),
@@ -210,6 +210,47 @@ fn division_wraps_minimum_integer_by_negative_one() {
);
}
#[test]
fn remainder_handles_integer_boundaries() {
for (dividend, divisor, expected) in [
(i32::MAX - 1, i32::MAX, i32::MAX - 1),
(7, -3, -2),
(-7, 3, 2),
(i32::MIN, -1, 0),
(-1, i32::MIN, -1),
(1, i32::MIN, i32::MIN + 1),
(0, i32::MAX, 0),
] {
let mut m = mock();
run(&mut m, "scoreboard objectives add boundary dummy").unwrap();
run(
&mut m,
&format!("scoreboard players set left boundary {dividend}"),
)
.unwrap();
run(
&mut m,
&format!("scoreboard players set right boundary {divisor}"),
)
.unwrap();
run(
&mut m,
"scoreboard players operation left boundary %= right boundary",
)
.unwrap();
assert_eq!(
run(&mut m, "scoreboard players get left boundary").unwrap(),
expected,
"{dividend} %= {divisor}"
);
assert_eq!(
run(&mut m, "scoreboard players get right boundary").unwrap(),
divisor
);
}
assert!(apply_operation("%=", 1, 0).is_err());
}
#[test]
fn triggers() {
let mut m = mock();
@@ -41,6 +41,8 @@ pub fn call(shared: &Shared, client: u32, short: &str, argument: &Value) -> Resu
.ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?,
),
),
"gamerules" => game.gamerules(client, None),
"gamerules/update" => game.gamerules(client, Some(argument)),
"serversettings/motd" => game.motd(client, None),
"serversettings/motd/set" => game.motd(
client,
@@ -366,6 +368,23 @@ impl Game<'_> {
Ok(reply["value"].clone())
}
/// `GameRulesService.get` and `update`: the game's rules as typed rules, or the one `update` set.
fn gamerules(&self, client: u32, update: Option<&Value>) -> Result<Value, Failure> {
let mut request = json!({ "method": "gamerules" });
if let Some(update) = update {
request["method"] = json!("gamerules/update");
request["value"] = update.clone();
}
let reply = self.ask(&format!("management {client} {request}"))?;
if let Some(invalid) = reply["invalid"].as_str() {
return Err(invalid_params(invalid));
}
if let Some(error) = reply["error"].as_str() {
return Err((INTERNAL_ERROR, Some(error.to_owned())));
}
Ok(reply["value"].clone())
}
/// `MinecraftServerSettingsServiceImpl.isAutoSave`: read the game's live save flag.
/// `MinecraftServerSettingsServiceImpl.setAutoSave`: update that flag and return its new value.
fn autosave(&self, client: u32, value: Option<bool>) -> Result<Value, Failure> {
@@ -683,6 +702,11 @@ pub fn list_notification(kind: &str, event: &Value) -> Option<(&'static str, Val
"notification/bans/added",
ban(("player", player()), &reason, source, expires),
),
// `MinecraftServer.onGameRuleChanged`: the rule as a typed rule (the event's own `type` is its kind).
"gamerule" => (
"notification/gamerules/updated",
json!({ "key": event["key"], "type": event["valueType"], "value": event["value"] }),
),
"bans_removed" => ("notification/bans/removed", player()),
"ip_bans_added" => (
"notification/ip_bans/added",
@@ -784,6 +808,17 @@ mod tests {
note("ip_bans_removed", json!({ "ip": "10.0.0.1" })),
("notification/ip_bans/removed", json!("10.0.0.1"))
);
// A game rule changed: the rule as a typed rule, whose `type` the event keeps as `valueType`.
assert_eq!(
note(
"gamerule",
json!({ "key": "minecraft:pvp", "valueType": "boolean", "value": false })
),
(
"notification/gamerules/updated",
json!({ "key": "minecraft:pvp", "type": "boolean", "value": false })
)
);
assert!(list_notification("kick", &event(json!({}))).is_none());
}
+2 -2
View File
@@ -79,8 +79,8 @@ addition; a change to one starts `/api/v2`, served beside `/api/v1`.
- **Backups**: restore a single dimension.
- **Mods on a dedicated server**: list, turn on and off, and their settings, once native mods can be built for the
server ([mods.md](../mods.md), [native-mods.md](../native-mods.md#multiplayer)).
- **Vanilla's management protocol**: `serversettings/*`, `gamerules`, the notifications of the game rules, the
server's activity and the world upgrade, and the status heartbeat (the others are sent). The methods answer
- **Vanilla's management protocol**: `serversettings/*` (the live settings not yet done), the notifications of
the server's activity and the world upgrade, and the status heartbeat (the others are sent). The methods answer
an internal error saying so; a kick's translatable message is sent as its key.
- **Compatibility tests**: golden replies per endpoint, and a `server.toml` from each release line.
+72 -2
View File
@@ -1045,7 +1045,8 @@ trusting native code is the player's decision, never an agent's.
7. **Install**: a new version, `native-<commit>-<key>`, a copy of the starting version's folder (resources
included, cloned on APFS, Btrfs and ReFS) with the built game in place of its own, and a `native-mods.json`
saying which release or Nightly it started from (`base`) and which commit, mods, horizon-mixins and Rust made
it. The instance the mods are installed in plays it, the default **Horizon** instance included: the build
it. The game lists its `mods` with their versions in a crash report's details (`Native Mods: id version, …`;
they stay in the local report, not in crash telemetry). The instance the mods are installed in plays it, the default **Horizon** instance included: the build
pins its version, as picking a version does. Updates of the launcher's version leave a pinned instance where
it is, and their clean-up never removes a native build. An instance built before this (the **Native mods**
instance the default home's mods used to go to) keeps its worlds and its build.
@@ -2246,6 +2247,73 @@ renderer list is installed once alongside the mod list. `render_needs` collects
run on the render thread while the frame is built, before any deferred GPU submission.
The renderer module re-exports the game's wgpu types.
### The typed API from mixin code
A helper is compiled inside its target, as a [Sponge Mixin](https://github.com/SpongePowered/Mixin/wiki) handler is merged
into its target class, so it can use the typed API. On a `Game` method, bind the game and coerce it to `dyn Host`:
```toml
[[injection]]
id = "count"
target = "horizon_client::game::Game<'_>::tick_mobs"
kind = "HEAD"
helper = "count.rs"
bindings = [{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" }]
```
```rust
fn count(game: &mut crate::game::Game<'_>) {
let host: &mut dyn horizon_client::mod_api::Host = game;
let holder = horizon_client::mod_api::Holder::Level;
if let Ok(old) = crate::game::example::COUNTER.get(&mut *host, holder) {
let _ = crate::game::example::COUNTER.set(host, holder, old + 1);
}
}
```
`COUNTER` is the `AttachmentType<i32>` of [Server attachments](#server-attachments), kept in a `[[module]]` of the
bundle (horizon-mixins' README): one with `file = "crates/horizon-client/src/game.rs"` and `name = "example"` is
`crate::game::example`. `Game` is not an `AttachmentHost` itself, so the `dyn Host` coercion is the step that matters;
any `Host` helper works on `host` too.
On a `GameLevel` method that takes `&mut self` (`native_random_tick`, `native_tick_block`, `native_perform_bonemeal`,
`native_is_bonemeal_success`), bind `{ name = "level", ty = "&mut dyn crate::mod_api::LevelRandomHost", value = "&mut *self" }`
for attachments and `random()`. Scheduling and `set_block_property` stay on the `LevelTicksHost` and `LevelWriteHost` the
hook points pass; `is_bonemeal_success` gets only a `LevelRandomHost`.
A trait injection gives the mod's `[api]` crate the same `Host` at that method. The `[api]` crate depends on
horizon-client's library, not on the game executable that helpers and modules are compiled into, so it declares its
own `AttachmentType<i32>` with the same `namespace` and `name`: the pair is the key, so both statics read one value.
```toml
[[injection]]
id = "count"
target = "horizon_client::game::Game<'_>::tick_mobs"
kind = "HEAD"
trait_method = "count"
bindings = [{ name = "host", ty = "&mut dyn crate::mod_api::Host", value = "&mut *self" }]
```
```rust
fn inject_example_count(&self, event: &mut InjectExampleCountEvent<'_>) {
let _ = COUNTER.get(&mut *event.host, Holder::Level);
}
```
The `[api]` crate reads `event.host` at any `Game` method horizon-mixins can inject into at HEAD (its README lists the
limits). `hook` and `hook_version` work in place of `target`.
The helper runs in every `Game` that calls its target, and attachments answer `MissingHolder` outside the server's game,
as they do in hooks.
A catalog hook's trait method, event and return are part of its `hook_version` contract ([Named hook
points](#named-hook-points), [Mods on the hook points](#mods-on-the-hook-points)). Later Horizons only add event fields
(`#[non_exhaustive]`), trait methods with default bodies, and `Host` methods. So a mod whose `[api]` package uses only catalog
hooks (trait methods, or injections by `hook`) and `Host` can set `"hooks"` in its `.hnmod`'s `horizon-native.json` to the
`NATIVE_HOOKS_API` it was made on.
A raw `target` like `tick_mobs` is not a hook point and may change in any commit: such a mod pins `horizon_revision` and
leaves `hooks` out.
### Testing a trait mod
Every `Host` method defaults to a panic naming it (`not implemented: Host::tell`), so a test double implements
@@ -2312,13 +2380,15 @@ static COUNTER: AttachmentType<i32> = AttachmentType {
}),
copy_on_death: false,
};
// Inside a server hook with `host: &mut dyn Host`:
// Inside a server hook, or mixin code, with `host: &mut dyn Host`:
if !COUNTER.has(host, Holder::Chunk([0, 0]))? { /* chunk carries no counter yet */ }
let old = COUNTER.get(host, Holder::Chunk([0, 0]))?;
COUNTER.set(host, Holder::Chunk([0, 0]), old + 1)?;
Ok::<(), AttachmentError>(())
```
For mixin code, see [The typed API from mixin code](#the-typed-api-from-mixin-code).
Use `Holder::Level`, `Holder::Chunk([x, z])`, `Holder::BlockEntity([x, y, z])`,
`Holder::Entity(id)`, or `Holder::Player(uuid)`. Entity IDs cover mobs, item
entities, boats, minecarts, armor stands, cushions, item frames, paintings and the
+5 -1
View File
@@ -517,7 +517,11 @@ runtime or new dependency. Server-sent events can replace the polling later if a
watcher what `add`, `remove` and `clear` do, as vanilla's `StoredUserList` tells its `NotificationService`,
so a change by the console, a command in the game or the protocol itself is told alike (`clear` once per
entry); the event stream carries them as `operators_added`, `bans_removed`... too;
- planned: `serversettings/*` and `gamerules`, the other notifications and the heartbeat.
- game rules: `gamerules` lists every rule the game has as a typed rule (`{key, type, value}`), `gamerules/update`
sets one (checked against its type and bounds, logged and set only when the value changes) and
`notification/gamerules/updated` follows every change, by that method or by `/gamerule`
(`Game::set_game_rule`);
- planned: the rest of `serversettings/*`, the activity and world-upgrade notifications and the heartbeat.
## 9. Benchmark plan (bots against vanilla and Paper)
+13
View File
@@ -1191,8 +1191,21 @@ def compute():
]
def scoreboard_remainder_boundaries():
operations = setup('/scoreboard objectives add boundary dummy')
for dividend, divisor in [(2147483646, 2147483647), (7, -3), (-2147483648, -1)]:
operations += setup(f'/scoreboard players set left boundary {dividend}',
f'/scoreboard players set right boundary {divisor}')
operations += [cmd('/scoreboard players operation left boundary %= right boundary'),
cmd('/scoreboard players get left boundary'),
cmd('/scoreboard players get right boundary')]
operations += setup('/scoreboard objectives remove boundary')
return operations
def social():
return [
*scoreboard_remainder_boundaries(),
*setup('/scoreboard objectives add trig trigger', '/scoreboard objectives add plain dummy'),
cmd('/trigger trig'),
cmd('/scoreboard players enable @s trig'),