mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 17:19:15 -10:00
Merge remote-tracking branch 'origin/main' into fix/7080-armor-stand-world-border
This commit is contained in:
commit
c769fc342c
24 files changed
+859
-122
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user