Merge main into #7005

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 14:34:04 +01:00
commit d0248722b6
28 files changed
+1016 -124

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
+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
@@ -8780,9 +8780,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
@@ -2186,7 +2186,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() {
@@ -2261,7 +2262,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.
@@ -2950,6 +2955,27 @@ impl Game<'_> {
self.native_player_level_changed(old_level);
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);
@@ -2972,8 +2998,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 })
);
}
}
@@ -126,7 +126,13 @@ impl Game<'_> {
if let Some(maps) = maps {
self.register_made_maps(maps);
}
let dropped = std::mem::take(&mut menu.dropped);
// A taken anvil result waits for AnvilCraftEvent.Pre: what the click
// dropped waits with it (lan_station_op throws it or refuses the take).
let dropped = if menu.anvil_craft.is_some() {
Vec::new()
} else {
std::mem::take(&mut menu.dropped)
};
let equipped = std::mem::take(&mut menu.equipped);
let brewed = std::mem::take(&mut menu.brewed);
let crafted = std::mem::take(&mut menu.crafted);
+54 -11
View File
@@ -13,7 +13,7 @@ use super::{
use horizon_client::{
containers::BlockEntity,
items::ItemStack,
menu::{Kind, Menu},
menu::{AnvilCraft, Kind, Menu},
};
use horizon_core::random::RandomSource;
use horizon_protocol::packets::play::{self, Clientbound as C};
@@ -80,6 +80,8 @@ impl Game<'_> {
return true;
};
let mut menu = Box::new(Menu::new(kind));
// Without a mod the take runs AnvilMenu.onTake's order in the click itself.
menu.anvil_hook = kind == Kind::Anvil && !horizon_client::mod_api::mods().is_empty();
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
&& kind == Kind::Enchanting
{
@@ -136,14 +138,42 @@ impl Game<'_> {
let slots: Vec<_> = (0..=r).map(|s| gs.menu.slot(s).cloned()).collect();
(slots, gs.menu.data[0])
});
self.guest_menu_op(host, gi, &mut gs.menu, op);
let recomputed = before.is_some_and(|(slots, cost)| {
cost != gs.menu.data[0]
|| slots
.iter()
.enumerate()
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
let mut undo = None;
let hook = gs.menu.anvil_hook;
self.guest_menu_op(host, gi, &mut gs.menu, |menu, env| {
undo = hook.then(|| menu.anvil_undo());
op(menu, env);
});
// AnvilCraftEvent.Pre: the taken result's onTake waits for the mods.
let mut anvil_craft = gs.menu.anvil_craft.take();
let mut cancelled = false;
if let Some(craft) = &anvil_craft {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
let mut cancel = false;
self.native_anvil_craft_pre(uuid, &name, gs.pos, craft, &mut cancel);
if cancel && let Some(undo) = undo {
// The take is refused: the result, inputs, cost, inventory
// and cursor are as before the click (the resync below
// tells the client).
anvil_craft = None;
cancelled = true;
self.guest_menu_op(host, gi, &mut gs.menu, |menu, _| menu.anvil_cancel(undo));
} else {
gs.menu.anvil_take(&self.menu_env());
let dropped = std::mem::take(&mut gs.menu.dropped);
self.drop_guest_items(host, gi, dropped);
}
}
// A cancelled take runs no createResult: the inputs are untouched.
let recomputed = !cancelled
&& before.is_some_and(|(slots, cost)| {
cost != gs.menu.data[0]
|| slots
.iter()
.enumerate()
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
});
if recomputed && gs.kind == StationKind::Anvil {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
@@ -172,7 +202,7 @@ impl Game<'_> {
if let Some(amount) = gs.menu.ground.take() {
self.grindstone_taken(gs.pos, amount);
}
self.lan_station_combined(host, conn, &mut gs);
self.lan_station_combined(host, conn, &mut gs, anvil_craft.as_ref());
if let Some(button) = gs.menu.enchant_button.take() {
self.lan_enchant(host, gi, &mut gs, button);
}
@@ -305,8 +335,15 @@ impl Game<'_> {
/// The rest of a taken anvil or smithing result's `onTake`: the guest
/// pays the levels, the anvil wears (level event 1030 or 1029), the
/// smithing table sounds (1044).
fn lan_station_combined(&mut self, host: &mut LanHost, conn: u32, gs: &mut GuestStation) {
/// smithing table sounds (1044). An anvil's `craft` is then reported to
/// the mods (`AnvilCraftEvent.Post`).
fn lan_station_combined(
&mut self,
host: &mut LanHost,
conn: u32,
gs: &mut GuestStation,
craft: Option<&AnvilCraft>,
) {
let change = std::mem::take(&mut gs.menu.level_change);
if change != 0 {
self.as_guest(host, conn, |g| g.give_levels(change));
@@ -320,6 +357,12 @@ impl Game<'_> {
let creative = gs.menu.creative;
// A broken anvil closes the menu on the next stillValid check.
self.anvil_used(gs.pos, creative);
if let Some(craft) = craft
&& let Some(guest) = host.hub.guests.iter().find(|g| g.conn == conn)
{
let (uuid, name) = (guest.uuid, guest.name.clone());
self.native_anvil_craft_post(uuid, &name, gs.pos, craft);
}
}
}
+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()));
@@ -4548,6 +4548,27 @@ impl Game<'_> {
) {
mod_api::dispatch_prepare_anvil(self, mod_api::mods(), player, name, pos, cost, result);
}
/// A player took an anvil's result at `pos` (`AnvilCraftEvent.Pre`), before the levels are charged, the inputs used up and the anvil worn. `craft` holds the inputs and the result as they were before. Set `cancel` to refuse the take: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. @API:native_anvil_craft_pre@.
pub(super) fn native_anvil_craft_pre(
&mut self,
player: u128,
name: &str,
pos: [i32; 3],
craft: &horizon_client::menu::AnvilCraft,
cancel: &mut bool,
) {
mod_api::dispatch_anvil_craft_pre(self, mod_api::mods(), player, name, pos, craft, cancel);
}
/// A player took an anvil's result at `pos` and the levels, inputs and anvil wear are done (`AnvilCraftEvent.Post`); a broken anvil is already air. `craft` holds the inputs and the result as they were before. Not called when `native_anvil_craft_pre` cancelled. @API:native_anvil_craft_post@.
pub(super) fn native_anvil_craft_post(
&mut self,
player: u128,
name: &str,
pos: [i32; 3],
craft: &horizon_client::menu::AnvilCraft,
) {
mod_api::dispatch_anvil_craft_post(self, mod_api::mods(), player, name, pos, craft);
}
/// A grindstone result was prepared for a server player at `pos`. Change it before it is sent. @API:prepare_grindstone@.
pub(super) fn native_prepare_grindstone(
&mut self,
@@ -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
+129 -1
View File
@@ -324,6 +324,14 @@ pub struct Menu {
/// a level) runs the rest of its `onTake` (the anvil's wear and level
/// event 1030 or 1029, the smithing table's 1044).
pub combined: bool,
/// The caller fires `AnvilCraftEvent.Pre` for a taken anvil result: its
/// `onTake` waits in `anvil_craft` for the caller to run `anvil_take`, or
/// to refuse the take with `anvil_cancel` (from an `anvil_undo` made
/// before the click).
pub anvil_hook: bool,
/// The anvil result taken while `anvil_hook` is set, as it was before
/// crafting.
pub anvil_craft: Option<AnvilCraft>,
/// The enchanting table's item changed to an enchantable one: the caller
/// (with a level) fills the costs and clues (`slotsChanged`'s
/// `access.execute`).
@@ -349,6 +357,26 @@ pub struct Anvil {
pub only_renaming: bool,
}
/// An anvil craft as `AnvilCraftEvent` reports it: the inputs and the result
/// taken, before the crafting changed anything.
#[derive(Clone, Debug, PartialEq)]
pub struct AnvilCraft {
pub left: Option<ItemStack>,
pub right: Option<ItemStack>,
pub output: ItemStack,
}
/// An anvil menu before a click, for `Menu::anvil_cancel`: the anvil's three
/// slots and cost, the player's slots and the cursor.
#[derive(Clone, Debug, PartialEq)]
pub struct AnvilUndo {
inputs: Vec<Option<ItemStack>>,
result: Option<ItemStack>,
cost: i32,
player: Vec<Option<ItemStack>>,
carried: Option<ItemStack>,
}
/// `StonecutterMenu`'s state besides its slots and `selectedRecipeIndex`.
#[derive(Clone, Default, Debug, PartialEq)]
pub struct Stonecutter {
@@ -742,6 +770,8 @@ impl Menu {
level_change: 0,
anvil: Anvil::default(),
combined: false,
anvil_hook: false,
anvil_craft: None,
enchant_changed: false,
enchant_button: None,
may_build: true,
@@ -1594,8 +1624,33 @@ impl Menu {
out
}
/// The anvil menu as it is before a click that may take its result.
pub fn anvil_undo(&self) -> AnvilUndo {
AnvilUndo {
inputs: self.containers[MAIN].clone(),
result: self.containers[RESULT][0].clone(),
cost: self.data[0],
player: self.containers[PLAYER].clone(),
carried: self.carried.clone(),
}
}
/// Refuses a take that `anvil_hook` deferred (a cancelled
/// `AnvilCraftEvent.Pre`): the result goes back in its slot and the
/// inputs, cost, inventory and cursor are as `undo` held them, so nothing
/// the click did (cursor, shift-click, swap, drop) is left.
pub fn anvil_cancel(&mut self, undo: AnvilUndo) {
self.containers[MAIN] = undo.inputs;
self.containers[RESULT][0] = undo.result;
self.data[0] = undo.cost;
self.containers[PLAYER] = undo.player;
self.carried = undo.carried;
self.anvil_craft = None;
self.dropped.clear();
}
/// `AnvilMenu.onTake`: the levels, the material used up, then the input.
fn anvil_take(&mut self, env: &Env) {
pub fn anvil_take(&mut self, env: &Env) {
let cost = self.data[0];
if !self.creative {
// Player.giveExperienceLevels(-cost).
@@ -1680,6 +1735,14 @@ impl Menu {
return;
}
if self.slots[s].role == Role::CombineResult && self.kind == Kind::Anvil {
if self.anvil_hook {
self.anvil_craft = Some(AnvilCraft {
left: self.containers[MAIN][0].clone(),
right: self.containers[MAIN][1].clone(),
output: taken.clone(),
});
return;
}
self.anvil_take(env);
return;
}
@@ -3492,6 +3555,71 @@ pub(crate) mod tests {
assert_eq!(menu.mod_slot(0, &stack), (false, 7));
}
/// A cancelled `AnvilCraftEvent.Pre` refuses the take: the result sits in
/// its slot, both inputs and the cost are as they were, and neither the
/// cursor nor the inventory holds the item (no dupe).
#[test]
fn a_cancelled_anvil_craft_leaves_the_menu_as_it_was() {
let root =
std::env::temp_dir().join(format!("horizon-anvil-cancel-{}", std::process::id()));
std::fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
let items = Items::for_tests(Default::default());
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
let brewing = Brewing::load(&root, &items, &PackOverlay::NONE);
let _ = std::fs::remove_dir_all(&root);
let (banners, station_recipes) = (Banners::default(), Default::default());
let (enchantments, names) = (Default::default(), Default::default());
let env = &Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
station_recipes: &station_recipes,
enchantments: &enchantments,
names: &names,
trade: None,
maps: None,
};
// Both a pick-up onto the cursor and a shift-click into the inventory.
for input in [Input::Pickup, Input::QuickMove] {
let mut menu = Menu::new(Kind::Anvil);
menu.creative = false;
menu.levels = 10;
menu.anvil_hook = true;
menu.set_slot(env, 0, stack("iron_sword", 1));
menu.set_slot(env, 1, stack("iron_ingot", 2));
menu.set_slot(env, 2, stack("iron_sword", 1));
menu.data[0] = 3;
let before = (
menu.slot(0).cloned(),
menu.slot(1).cloned(),
menu.slot(2).cloned(),
);
let undo = menu.anvil_undo();
menu.click(env, 2, 0, input).unwrap();
// The take waits for the mods, and the click moved the result.
let craft = menu.anvil_craft.take().expect("the take is deferred");
assert_eq!(craft.output, before.2.clone().unwrap());
assert_eq!(menu.slot(2), None);
assert_eq!(menu.data[0], 3);
menu.anvil_craft = Some(craft);
menu.anvil_cancel(undo);
assert_eq!(
(
menu.slot(0).cloned(),
menu.slot(1).cloned(),
menu.slot(2).cloned()
),
before
);
assert_eq!(menu.data[0], 3);
assert_eq!(menu.level_change, 0);
assert_eq!(menu.carried, None);
assert!((0..36).all(|i| menu.player_item(i).is_none()));
assert!(menu.anvil_craft.is_none() && menu.dropped.is_empty());
}
}
/// A mod menu type: three slots, then a result (slot 3).
fn mod_menu() -> Kind {
Kind::Mod(Box::leak(Box::new(crate::containers::ModMenuType {
+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]
+72
View File
@@ -3173,6 +3173,19 @@ pub trait Hooks: Send + Sync {
let _ = event;
Flow::Continue
}
/// A player took an anvil's result at `pos` and the levels, inputs and anvil wear are done (`AnvilCraftEvent.Post`); a broken anvil is already air. `craft` holds the inputs and the result as they were before. Not called when `native_anvil_craft_pre` cancelled. @API:native_anvil_craft_post@.
fn anvil_craft_post(&self, host: &mut dyn Host, event: &mut AnvilCraftPostEvent<'_>) {
let _ = host;
let _ = event;
}
/// A player took an anvil's result at `pos` (`AnvilCraftEvent.Pre`), before the levels are charged, the inputs used up and the anvil worn. `craft` holds the inputs and the result as they were before. Set `cancel` to refuse the take: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. @API:native_anvil_craft_pre@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn anvil_craft_pre(&self, host: &mut dyn Host, event: &mut AnvilCraftPreEvent<'_>) -> Flow {
let _ = host;
let _ = event;
Flow::Continue
}
/// A player takes an item from an armor stand or puts one on it
/// (`ArmorStand.interactAt`, after the stand's disabled-slot checks and
/// `swapItem`'s take/put locks, before anything moves): `stand_id` is the
@@ -10718,6 +10731,65 @@ pub fn dispatch_moisture_change(
}
}
}
/// What [`Hooks::anvil_craft_post`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct AnvilCraftPostEvent<'a> {
pub player: u128,
pub name: &'a str,
pub pos: [i32; 3],
pub craft: &'a horizon_client::menu::AnvilCraft,
}
pub fn dispatch_anvil_craft_post(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
player: u128,
name: &str,
pos: [i32; 3],
craft: &horizon_client::menu::AnvilCraft,
) {
let mut event = AnvilCraftPostEvent {
player,
name,
pos,
craft,
};
for mod_hook in mods {
mod_hook.anvil_craft_post(host, &mut event);
}
}
/// What [`Hooks::anvil_craft_pre`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct AnvilCraftPreEvent<'a> {
pub player: u128,
pub name: &'a str,
pub pos: [i32; 3],
pub craft: &'a horizon_client::menu::AnvilCraft,
}
pub fn dispatch_anvil_craft_pre(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
player: u128,
name: &str,
pos: [i32; 3],
craft: &horizon_client::menu::AnvilCraft,
cancel: &mut bool,
) {
if *cancel {
return;
}
let mut event = AnvilCraftPreEvent {
player,
name,
pos,
craft,
};
for mod_hook in mods {
if matches!(mod_hook.anvil_craft_pre(host, &mut event), Flow::Cancel) {
*cancel = true;
break;
}
}
}
/// What [`Hooks::armor_stand_manipulate`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ArmorStandManipulateEvent<'a> {
+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.
+74 -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.
@@ -1600,6 +1601,8 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
- **`native_enchant_item(player, name, pos, item, levels)`**: a player applied an enchantment at `pos`; `item` is the finished item and `levels` the levels charged. Guest state is not swapped in.
- **`native_prepare_item_enchant(player, name, pos, costs)`**: enchanting offers were calculated at `pos`; change the three offer costs before they are sent to the player's menu. Guest state is not swapped in.
- **`native_prepare_anvil(player, name, pos, result, cost)`**: an anvil result was prepared for a player at `pos`; change the result or level cost before it is sent to their menu. Guest state is not swapped in.
- **`native_anvil_craft_pre(player, name, pos, craft, cancel)`**: a player took an anvil's result at `pos` (NeoForge's `AnvilCraftEvent.Pre`), before the levels are charged, the inputs used up and the anvil worn. `craft` has the left and right inputs and the result as they were before. Set `cancel` to refuse the take and skip `native_anvil_craft_post`: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. Fires on the server only; the window's predicted take does not call it.
- **`native_anvil_craft_post(player, name, pos, craft)`**: the levels, inputs and anvil wear of that take are done (NeoForge's `AnvilCraftEvent.Post`); a broken anvil is already air. `craft` is the same as in `native_anvil_craft_pre`. Not called when it cancelled.
- **`native_prepare_grindstone(player, name, pos, result)`**: a grindstone result was prepared for a player at `pos`; change it before it is sent to their menu. Guest state is not swapped in.
- **`native_prepare_smithing(player, name, pos, result)`**: a smithing result was prepared for a player at `pos`; change it before it is sent to their menu. Guest state is not swapped in.
- **`native_prepare_item_craft(player, name, pos, result)`**: a crafting-grid result was prepared; `pos` is the crafting table, or None for the player's inventory grid. Change it before it is sent to the menu. Guest state is not swapped in.
@@ -2287,6 +2290,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
@@ -2353,13 +2423,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'),
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "anvil_craft_post",
"target": "horizon_client::game::native_hooks::Game<'_>::native_anvil_craft_post",
"side": "server",
"neoforge": "AnvilCraftEvent.Post",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A player finished taking an anvil's result (`native_anvil_craft_post`).",
"doc_summary": "A player took an anvil's result: `native_anvil_craft_post(player, name, pos, craft)` (NeoForge's `AnvilCraftEvent.Post`) is heard after the levels, the inputs and the anvil wear are done, so a broken anvil is already air. `craft` holds the inputs and the result as they were before. Not heard when `native_anvil_craft_pre` cancelled. Bundles need \"hooks\": @API:native_anvil_craft_post@."
}
@@ -0,0 +1,17 @@
{
"hooks": [
{
"name": "anvil_craft_pre",
"target": "horizon_client::game::native_hooks::Game<'_>::native_anvil_craft_pre",
"side": "server",
"cancellable": true,
"neoforge": "AnvilCraftEvent.Pre",
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "A player took an anvil's result (`native_anvil_craft_pre`).",
"doc_summary": "A player took an anvil's result: `native_anvil_craft_pre(player, name, pos, craft, cancel)` (NeoForge's `AnvilCraftEvent.Pre`) runs before the levels are charged, the inputs used up and the anvil worn. `craft` holds the left and right inputs and the result as they were before. Set `cancel` to refuse the take and skip the matching `native_anvil_craft_post`: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. Bundles need \"hooks\": @API:native_anvil_craft_pre@."
}
+3
View File
@@ -1130,6 +1130,9 @@ mod tests {
pub mod camera {
pub struct CameraEntity;
}
pub mod menu {
pub struct AnvilCraft;
}
pub mod fog {
#[derive(Clone, Copy)]
pub struct FogType;