fix(mobs): let farmers tend registered mod crops

Recognize farmland and crop templates through their retained class chains and plant loaded tagged block items in inventory order. Add vanilla-oracle classification and real content-root regressions.

Co-Authored-By: GPT-6.1 Sol <[email protected]>
This commit is contained in:
Aaron WangandGPT-6.1 Sol committed 2026-11-01 18:22:33 -05:00
1 parent f8bfa06b90
commit 873ccc1ecf
7 files changed
+460 -46

No files matched your search

+18 -15
View File
@@ -345,25 +345,28 @@ pub fn work_sound(profession: u8) -> Option<&'static str> {
}
/// A `CropBlock` at `p`.
pub fn crop(w: &MobWorld, p: [i32; 3]) -> bool {
matches!(
w.block_name(p[0], p[1], p[2]),
"minecraft:wheat"
| "minecraft:carrots"
| "minecraft:potatoes"
| "minecraft:beetroots"
| "minecraft:torchflower_crop"
)
w.facts
.get(w.world.block(p[0], p[1], p[2]))
.has_class("CropBlock")
}
/// `CropBlock.isMaxAge` for the block at `p`.
pub fn ripe_crop(w: &MobWorld, p: [i32; 3]) -> bool {
let max = match w.block_name(p[0], p[1], p[2]) {
"minecraft:wheat" | "minecraft:carrots" | "minecraft:potatoes" => "7",
"minecraft:beetroots" => "3",
// TorchflowerCropBlock.getMaxAge is 2, past its age property (0-1):
// the crop is never at max age (age 2 is the torchflower itself).
_ => return false,
let facts = w.facts.get(w.world.block(p[0], p[1], p[2]));
if !facts.has_class("CropBlock") {
return false;
}
// The class chain survives content registration, including the two
// getMaxAge overrides. PitcherCropBlock is not a CropBlock.
let max = if facts.has_class("BeetrootBlock") {
3
} else if facts.has_class("TorchflowerCropBlock") {
2
} else {
7
};
w.prop(p[0], p[1], p[2], "age") == Some(max)
w.prop(p[0], p[1], p[2], "age")
.and_then(|age| age.parse::<i32>().ok())
.is_some_and(|age| age >= max)
}
/// The gift loot table of a villager (`GiveGiftToHero.getLootTableToThrow`).
@@ -775,7 +775,7 @@ fn sense_babies(m: &mut Mob, w: &MobWorld) {
}
mem(m).visible_villager_babies.set_list(babies);
}
/// `SecondaryPoiSensor`: a farmer's farmland within four blocks.
/// `SecondaryPoiSensor.doTick`: a farmer's farmland within four blocks.
fn sense_secondary(m: &mut Mob, w: &MobWorld) {
let mut sites = Vec::new();
if super::villager::is_farmer(m) {
@@ -784,7 +784,7 @@ fn sense_secondary(m: &mut Mob, w: &MobWorld) {
for y in -2..=2 {
for z in -4..=4 {
let p = [c[0] + x, c[1] + y, c[2] + z];
if w.block_name(p[0], p[1], p[2]) == "minecraft:farmland" {
if farmland(w, p) {
sites.push(p);
}
}
@@ -3494,6 +3494,10 @@ fn restock_if_due(m: &mut Mob, w: &MobWorld) {
t.restock(w.game_time);
}
}
#[cfg(test)]
#[path = "villager_farming_tests.rs"]
mod farming_tests;
/// `HarvestFarmland.validFarmlandAroundVillager`: ripe crops and bare
/// farmland around the villager.
fn farmland_around(m: &Mob, w: &MobWorld) -> Vec<[i32; 3]> {
@@ -3514,10 +3518,16 @@ fn farmland_around(m: &Mob, w: &MobWorld) -> Vec<[i32; 3]> {
}
valid
}
/// Farmland subclasses include mod templates.
fn farmland(w: &MobWorld, p: [i32; 3]) -> bool {
w.facts
.get(w.world.block(p[0], p[1], p[2]))
.has_class("FarmlandBlock")
}
/// `HarvestFarmland.validPos`: a ripe CropBlock or air above farmland.
fn farmland_valid(w: &MobWorld, p: [i32; 3]) -> bool {
super::villager::ripe_crop(w, p)
|| w.facts.is_air(w.world.block(p[0], p[1], p[2]))
&& w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:farmland"
|| w.facts.is_air(w.world.block(p[0], p[1], p[2])) && farmland(w, [p[0], p[1] - 1, p[2]])
}
/// `HarvestFarmland.getValidFarmland`: one picked by the level random.
fn pick_farmland(valid: &[[i32; 3]], cx: &mut Bx) -> Option<[i32; 3]> {
@@ -3527,17 +3537,13 @@ fn pick_farmland(valid: &[[i32; 3]], cx: &mut Bx) -> Option<[i32; 3]> {
let i = cx.lr.next_int_bound(valid.len() as i32).unwrap_or(0) as usize;
Some(valid[i])
}
/// The seeds a villager plants (`VILLAGER_PLANTABLE_SEEDS`) and their crops.
fn seed_crop(item: &str) -> Option<&'static str> {
Some(match item {
"minecraft:wheat_seeds" => "minecraft:wheat",
"minecraft:potato" => "minecraft:potatoes",
"minecraft:carrot" => "minecraft:carrots",
"minecraft:beetroot_seeds" => "minecraft:beetroots",
"minecraft:torchflower_seeds" => "minecraft:torchflower_crop",
"minecraft:pitcher_pod" => "minecraft:pitcher_crop",
_ => return None,
})
/// A tagged BlockItem plants its block's default state.
fn seed_crop(w: &MobWorld, item: &str) -> Option<horizon_world::registry::BlockStateId> {
let items = w.items?;
if !items.item_in_tag("minecraft:villager_plantable_seeds", item) {
return None;
}
items.get(item)?.block
}
/// `HarvestFarmland.tick` at the farmland: harvest a ripe crop, sow bare
/// farmland, and move on from a growing crop.
@@ -3559,23 +3565,22 @@ fn harvest_tick(
if ripe {
cx.events.push(MobEvent::BreakBlock { pos: p, drop: true });
}
if air && w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:farmland" {
if air && farmland(w, [p[0], p[1] - 1, p[2]]) {
let e = super::villager::ext_mut(m);
if let Some(slot) = e
if let Some((slot, crop)) = e
.inventory
.iter()
.position(|s| s.count > 0 && seed_crop(&s.item).is_some())
.enumerate()
.filter(|(_, s)| s.count > 0)
.find_map(|(i, s)| seed_crop(w, &s.item).map(|crop| (i, crop)))
{
let crop = seed_crop(&e.inventory[slot].item).unwrap_or("minecraft:wheat");
e.inventory[slot].count -= 1;
if e.inventory[slot].count == 0 {
e.inventory.remove(slot);
}
cx.events.push(MobEvent::SetBlockName {
cx.events.push(MobEvent::SetBlock {
pos: p,
name: crop,
effect: false,
flags: horizon_world::level::flags::UPDATE_ALL,
state: crop,
});
cx.events.push(MobEvent::Sound(
"item.crop.plant",
@@ -0,0 +1,322 @@
use super::*;
use crate::{items::ItemStack, mod_content::ContentMod};
use std::{
path::PathBuf,
sync::{Arc, OnceLock},
};
fn resources() -> &'static std::path::Path {
static ROOT: OnceLock<PathBuf> = OnceLock::new();
ROOT.get_or_init(|| {
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
let scratch = std::env::temp_dir().join(format!("fresh-farming-{}", std::process::id()));
let dir = scratch.join("mod");
let mut files = vec![(
"horizon-mod.toml".to_owned(),
"id = \"fieldtest\"\nname = \"Field test\"\nversion = \"1.0\"\napi = \"0.2\"\nside = \"both\"\ncapabilities = [\"registries\"]\n".to_owned(),
)];
for (id, template) in [
("soil", "farmland"),
("grain", "wheat"),
("roots", "beetroots"),
("flower", "torchflower_crop"),
("pod", "pitcher_crop"),
("fake_crop", "stone"),
] {
files.push((
format!("data/fieldtest/horizon/block/{id}.json"),
format!(r#"{{"template":"minecraft:{template}"}}"#),
));
}
files.push((
"data/minecraft/tags/item/villager_plantable_seeds.json".into(),
r#"{"replace":true,"values":["fieldtest:grain","fieldtest:roots","minecraft:stick"]}"#.into(),
));
for (name, text) in files {
let path = dir.join(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
let package = horizon_mods::Package::read(&dir).unwrap();
let content = horizon_mods::Content::read(&dir, &package.manifest).unwrap();
let out = scratch.join("resources");
crate::mod_content::build(&base, &[ContentMod { id: "fieldtest".into(), content }], &out)
.unwrap();
out
})
}
fn farmer(types: &super::super::MobTypes) -> Mob {
let mut m = Mob::new(
1,
Arc::new(types.types["minecraft:villager"].clone()),
Vec3::new(0.5, 1.0, 0.5),
0.0,
);
super::super::villager::set_profession(&mut m, 5);
m
}
fn harvest() -> B {
B::HarvestFarmland {
t: timed(60, 60),
next_ok: 0,
target: None,
valid: Vec::new(),
worked: 0,
}
}
fn put(
world: &mut crate::world::World,
x: i32,
y: i32,
state: horizon_world::registry::BlockStateId,
) {
if !world.chunks.contains_key(&(x >> 4, 0)) {
world
.chunks
.insert((x >> 4, 0), Arc::new(crate::chunk::Chunk::empty(x >> 4, 0)));
}
world.set(x, y, 0, state).unwrap();
}
#[test]
#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"]
fn mod_farmland_is_a_secondary_site_and_farming_target() {
assert!(super::super::world::with_resource_test_world(
resources(),
&[],
true,
|world, reg| {
put(world, 0, 0, reg.default_state("fieldtest:soil").unwrap());
},
|w, types| {
let mut m = farmer(types);
sense_secondary(&mut m, w);
assert_eq!(
mem(&mut m).secondary_job_site.value(),
Some(vec![[0, 0, 0]])
);
let mut lr = LegacyRandom::new(11);
let mut events = Vec::new();
let mut cx = Bx {
w,
lr: &mut lr,
events: &mut events,
now: 1000,
refresh: false,
};
assert!(harvest().try_start(&mut m, &mut cx));
let mut denied = w.clone();
denied.mob_griefing = false;
mem(&mut m).look_target.clear();
mem(&mut m).walk_target.clear();
cx.w = &denied;
assert!(!harvest().try_start(&mut m, &mut cx));
}
));
}
#[test]
#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"]
fn mod_crops_are_harvested_only_at_their_vanilla_templates_max_age() {
assert!(super::super::world::with_resource_test_world(
resources(),
&[],
true,
|world, reg| {
for (x, block, age) in [
(0, "grain", "6"),
(1, "grain", "7"),
(2, "roots", "3"),
(3, "flower", "1"),
(4, "pod", "4"),
] {
let mut state = reg
.state(reg.default_state(&format!("fieldtest:{block}")).unwrap())
.unwrap()
.clone();
state.properties.insert("age".into(), age.into());
put(world, x, 1, reg.resolve(&state).unwrap());
}
put(
world,
5,
1,
reg.default_state("fieldtest:fake_crop").unwrap(),
);
},
|w, types| {
let mut m = farmer(types);
let mut lr = LegacyRandom::new(12);
let mut events = Vec::new();
for (x, ripe) in [
(0, false),
(1, true),
(2, true),
(3, false),
(4, false),
(5, false),
] {
events.clear();
harvest_tick(
&mut m,
&mut Bx {
w,
lr: &mut lr,
events: &mut events,
now: 1000,
refresh: false,
},
&mut 0,
&mut Some([x, 1, 0]),
&mut Vec::new(),
);
assert_eq!(events.iter().any(|e| matches!(e, MobEvent::BreakBlock { pos, drop: true } if *pos == [x, 1, 0])), ripe, "x={x}");
}
}
));
}
#[test]
#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"]
fn farming_uses_the_first_tagged_block_item_and_consumes_one_seed() {
assert!(super::super::world::with_resource_test_world(
resources(),
&[],
true,
|world, reg| {
for x in 0..=1 {
put(world, x, 0, reg.default_state("fieldtest:soil").unwrap());
}
},
|w, types| {
let mut m = farmer(types);
let inventory = &mut super::super::villager::ext_mut(&mut m).inventory;
*inventory = vec![
ItemStack::new("minecraft:wheat_seeds", 2),
ItemStack::new("minecraft:stick", 2),
ItemStack::new("fieldtest:flower", 2),
ItemStack::new("fieldtest:roots", 1),
ItemStack::new("fieldtest:grain", 2),
];
let mut lr = LegacyRandom::new(13);
let mut events = Vec::new();
harvest_tick(
&mut m,
&mut Bx {
w,
lr: &mut lr,
events: &mut events,
now: 1000,
refresh: false,
},
&mut 0,
&mut Some([0, 1, 0]),
&mut Vec::new(),
);
let planted = events.iter().find_map(|e| match e {
MobEvent::SetBlock { state, .. } => {
Some(w.registry.state(*state).unwrap().name.as_str())
}
MobEvent::SetBlockName { name, .. } => Some(*name),
_ => None,
});
assert_eq!(planted, Some("fieldtest:roots"));
let inventory = &super::super::villager::ext(&m).unwrap().inventory;
assert_eq!(
inventory
.iter()
.map(|s| (&*s.item, s.count))
.collect::<Vec<_>>(),
vec![
("minecraft:wheat_seeds", 2),
("minecraft:stick", 2),
("fieldtest:flower", 2),
("fieldtest:grain", 2)
]
);
events.clear();
harvest_tick(
&mut m,
&mut Bx {
w,
lr: &mut lr,
events: &mut events,
now: 1001,
refresh: false,
},
&mut 0,
&mut Some([1, 1, 0]),
&mut Vec::new(),
);
let expected = w.registry.default_state("fieldtest:grain").unwrap();
assert!(events.iter().any(
|e| matches!(e, MobEvent::SetBlock { pos: [1, 1, 0], state } if *state == expected)
));
assert_eq!(
super::super::villager::ext(&m)
.unwrap()
.inventory
.last()
.unwrap()
.count,
1
);
assert_eq!(
events
.iter()
.filter(|e| matches!(e, MobEvent::Sound("item.crop.plant", _, 1.0, 1.0)))
.count(),
1
);
}
));
}
#[test]
#[ignore = "tools/test_villager_farming_parity.py supplies the vanilla oracle"]
fn vanilla_farming_classification_matches_the_oracle() {
let path = std::env::var_os("HORIZON_FARMING_ORACLE").expect("vanilla oracle output");
let rows: Vec<(u32, Vec<bool>)> = std::fs::read_to_string(path)
.unwrap()
.lines()
.map(|line| {
let mut fields = line.split_whitespace();
(
fields.next().unwrap().parse().unwrap(),
fields.map(|f| f.parse().unwrap()).collect(),
)
})
.collect();
assert!(!rows.is_empty());
assert!(super::super::world::with_built_test_world(
&[],
|world, _| {
for (x, (state, _)) in rows.iter().enumerate() {
put(
world,
x as i32,
0,
horizon_world::registry::BlockStateId(*state),
);
}
},
|w, _| {
for (x, (state, expected)) in rows.iter().enumerate() {
let p = [x as i32, 0, 0];
assert_eq!(
vec![
super::super::villager::crop(w, p),
super::super::villager::ripe_crop(w, p),
farmland_valid(w, [p[0], 1, 0])
],
*expected,
"state={state}"
);
}
}
));
}
+19 -8
View File
@@ -417,24 +417,35 @@ pub(crate) fn with_built_test_world_rules(
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
with_resource_test_world(&root, others, mob_griefing, build, f)
}
/// The same mob world loaded from a content mod's resource root.
#[cfg(test)]
pub(crate) fn with_resource_test_world(
root: &std::path::Path,
others: &[Seen],
mob_griefing: bool,
build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry),
f: impl FnOnce(&MobWorld<'_>, &super::MobTypes),
) -> bool {
if !root.join("entity-types.json").exists() || !root.join("movement-properties.nbt").exists() {
return false;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let assets = crate::assets::Assets::load(root).unwrap();
let registry = assets.registry;
let facts = BlockFacts::load(&root, registry).unwrap();
let motion = crate::motion::MotionResources::load(&root, &assets).unwrap();
let data = super::MobData::load(&root, &facts);
let types = super::MobTypes::load(&root).unwrap();
let biomes = crate::biome::Biomes::load(&root).unwrap();
let facts = BlockFacts::load(root, registry).unwrap();
let motion = crate::motion::MotionResources::load(root, &assets).unwrap();
let data = super::MobData::load(root, &facts);
let types = super::MobTypes::load(root).unwrap();
let biomes = crate::biome::Biomes::load(root).unwrap();
let items = crate::items::Items::load(
&root,
root,
registry,
&facts,
&horizon_world::loot::PackOverlay::NONE,
)
.unwrap();
let collision = crate::collisions::load(&root, registry).unwrap();
let collision = crate::collisions::load(root, registry).unwrap();
let air = registry.default_state("minecraft:air").unwrap();
let mut world = crate::world::World::new(air, 0);
build(&mut world, registry);
+10
View File
@@ -11,6 +11,16 @@ switch. The first install shows a trust notice, and every build waits behind a r
after a review (its later releases are checked by machine); and they play in a game of their own, built for the
instance they are installed in.
## Villager farming
Farmers recognize mod blocks made from farmland and `CropBlock` templates, including declarations from
`native_blocks`. A crop block's generated item is its seed: add that item to
`minecraft:villager_plantable_seeds` to let farmers plant its default block state from their inventory.
Untagged items and tagged items without a block are skipped. Crop maturity follows the template's class:
beetroots mature at age 3, ordinary crops at 7; torchflower crops never reach their maximum age 2 while
still crops. Pitcher crops are not `CropBlock`s and are not harvested by this behavior. Farming still
requires a farmer at work and `mob_griefing` enabled.
## Pinned to a Nightly
A native mod builds on one exact Horizon commit: `[compatibility] horizon_revision` in its `horizon-mixin.toml`,
@@ -0,0 +1,31 @@
package dev.horizonlab.parity;
import java.nio.file.Files;
import java.nio.file.Path;
import net.minecraft.SharedConstants;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.CropBlock;
import net.minecraft.world.level.block.FarmlandBlock;
/** HarvestFarmland's class and maturity checks, including non-CropBlock age properties. */
public final class VanillaVillagerFarmingOracle {
public static void main(String[] args) throws Exception {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
try (var out = Files.newBufferedWriter(Path.of(args[0]))) {
for (String name : new String[] {"wheat", "carrots", "potatoes", "beetroots",
"torchflower_crop", "pitcher_crop", "cocoa", "farmland", "stone"}) {
var block = BuiltInRegistries.BLOCK.getValue(Identifier.parse("minecraft:" + name));
for (var state : block.getStateDefinition().getPossibleStates()) {
boolean crop = block instanceof CropBlock;
boolean ripe = block instanceof CropBlock c && c.isMaxAge(state);
out.write(Block.getId(state) + " " + crop + " " + ripe + " " + (block instanceof FarmlandBlock));
out.newLine();
}
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Compare villager farming classification with the actual vanilla 26.3 block objects.
Also runs the content-mod farming regressions. Outputs are private dev data, not game resources.
"""
import os
from pathlib import Path
try:
from . import lab, oracle_harness, test_rust_parity as base, test_model_baking_parity as baking
except ImportError:
import lab, oracle_harness, test_rust_parity as base, test_model_baking_parity as baking
def main():
build = Path(os.environ.get("HORIZON_FARMING_PARITY_DIR", lab.LOCAL / "villager-farming-parity"))
build.mkdir(parents=True, exist_ok=True)
lock, jars, _ = base.verify_artifacts()
classes, dependencies, _ = oracle_harness.compile_oracle(
build, "VanillaVillagerFarmingOracle", jars, baking.client_libraries(), lock["minecraft"]["java_major"])
expected = build / "vanilla.txt"
oracle_harness.java_run(build, classes, dependencies, "VanillaVillagerFarmingOracle", [expected], "vanilla")
env = dict(lab.game_env(), HORIZON_FARMING_ORACLE=str(expected))
oracle_harness.run_logged([
"cargo", "test", "--locked", "-p", "horizon-client-lib", "--lib",
"mobs::villager_brain::farming_tests", "--", "--include-ignored", "--nocapture",
], build, "rust", env=env)
print(f"{len(expected.read_text().splitlines())} vanilla block states match; content-mod farming regressions pass")
if __name__ == "__main__":
main()