mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 22:43:47 -10:00
Merge main into #7005
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
d0248722b6
28 files changed
+1016
-124
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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+74
-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.
|
||||
@@ -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
@@ -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'),
|
||||
|
||||
@@ -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@."
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user