Merge pull request #9713 from Hiuoy/feat/8622-mod-goals

🤖 Agent: with no mods the new hook costs one HOOKS.get() and a walk of an empty mod list per mob created, not per tick, and it runs before the float check, so a mod's goals::Float floats the navigation as FloatGoal does. The fragment stamps cleanly against main (test_stamp_native_hooks passes in pr_ready's Python tests). Pushed 9861c8352: living_mod_types now uses a temp folder per call, since parallel tests were deleting each other's folder. Merged with main, pr_ready --quick passes on Linux (4331 tests, launcher included).
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-11-05 08:36:12 +01:00
commit 41d5649ed5
12 files changed
+201 -5

No files matched your search

+3
View File
@@ -155,6 +155,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
living_jump: |entity, kind| {
crate::mod_api::dispatch_living_jump(crate::mod_api::mods(), entity, kind);
},
register_goals: |mob| {
crate::mod_api::dispatch_register_goals(crate::mod_api::mods(), mob);
},
ingredient: |kind, data, items| {
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
},
+93 -2
View File
@@ -455,8 +455,13 @@ pub(super) fn living_mod_types()
"ruby:settler":{{{villager},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.5}},"template":"minecraft:villager"}},
"ruby:bolt":{{"width":0.25,"height":0.25,"eye_height":0.0,"category":"misc","fire_immune":false,"template":"minecraft:snowball"}}}}"#
);
let root =
std::env::temp_dir().join(format!("horizon-living-mod-types-{}", std::process::id()));
// A folder per call: tests run in parallel in one process, and each removes its own.
static CALLS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let call = CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"horizon-living-mod-types-{}-{call}",
std::process::id()
));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("entity-types.json"), table).unwrap();
let types = horizon_client::mobs::MobTypes::load_for_server(&root).unwrap();
@@ -610,4 +615,90 @@ mod tests {
assert!(not_built(&dir.join("missing"), "", &[]).is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
/// A trait mod's `register_goals` (`Mob.registerGoals`) gets every new
/// mob: the goals of its type replace its template's, its `Float` goal
/// floats the navigation as FloatGoal's constructor does, and the
/// template's other types keep their goals. The only test here that
/// installs the client library's hooks.
#[test]
fn a_mods_register_goals_replaces_its_types_goals() {
use horizon_client::mobs::{
Mob,
goals::{self, Cx, Goal},
};
use horizon_client::mod_api::{Hooks, RegisterGoalsEvent, dispatch_register_goals};
use horizon_client::native_client_hooks::{NativeClientHooks, install};
use horizon_core::geometry::Vec3;
// docs/native-mods.md's example.
#[derive(Clone, Debug)]
struct ToPost {
post: [f64; 3],
}
impl Goal for ToPost {
fn flags(&self) -> u8 {
goals::MOVE
}
fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
let [x, y, z] = self.post;
let (dx, dy, dz) = (m.position.x - x, m.position.y - y, m.position.z - z);
dx * dx + dy * dy + dz * dz > 16.0
}
fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
!m.ai.nav.is_done()
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
m.nav_move_to(cx.w, self.post, 1.0);
}
fn box_clone(&self) -> Box<dyn Goal> {
Box::new(self.clone())
}
}
struct Guards;
impl Hooks for Guards {
fn register_goals(&self, event: &mut RegisterGoalsEvent<'_>) {
if event.mob.kind.id == "ruby:guard" {
let ai = &mut event.mob.ai;
ai.goals.clear();
ai.targets.clear();
ai.goals.add(0, goals::Float);
ai.goals.add(
1,
ToPost {
post: [0.5, 64.0, 0.5],
},
);
}
}
}
static MODS: std::sync::LazyLock<Vec<Box<dyn Hooks>>> =
std::sync::LazyLock::new(|| vec![Box::new(Guards)]);
let installed = install(NativeClientHooks {
menu_slot_rules: |_, _, _, _, _| {},
living_heal: |_, _, _| {},
player_chat: |_, _, _, _, _| {},
living_breathe: |_, _, _, _, _| {},
living_drown: |_, _, _, _, _, _| {},
living_fall: |_, _, _, _, _| {},
living_jump: |_, _| {},
register_goals: |mob| dispatch_register_goals(&MODS, mob),
ingredient: |_, _, _| {},
ingredient_test: |_, _, _, _| {},
});
assert!(installed.is_ok());
let types = living_mod_types();
let mut guard = (*types["ruby:ghoul"]).clone();
guard.id = "ruby:guard".into();
let at = Vec3::new(0.5, 64.0, 0.5);
let guard = Mob::new(1, std::sync::Arc::new(guard), at, 0.0);
let ghoul = Mob::new(2, types["ruby:ghoul"].clone(), at, 0.0);
assert!(guard.ai.goals.has("Float") && guard.ai.goals.has("ToPost"));
assert!(!guard.ai.goals.has("RandomLookAround"));
assert!(!guard.ai.targets.has("NearestTarget"));
assert!(guard.ai.nav.can_float);
assert!(ghoul.ai.goals.has("RandomLookAround") && !ghoul.ai.goals.has("ToPost"));
assert!(ghoul.ai.targets.has("NearestTarget"));
assert!(!ghoul.ai.nav.can_float, "a zombie has no FloatGoal");
}
}
@@ -11045,6 +11045,8 @@ mod tests {
let motion_hooks = free_hooks(include_str!("../motion.rs"));
// The chat relay's (`horizon_client_lib::lan::hub::chat`).
let chat_hooks = free_hooks(include_str!("../lan/hub/chat.rs"));
// The mob AI's (`horizon_client_lib::mobs::ai`).
let mob_hooks = free_hooks(include_str!("../mobs/ai.rs"));
let declared: BTreeSet<_> = section
.lines()
.chain(level_section.lines())
@@ -11064,6 +11066,7 @@ mod tests {
.chain(landing_hooks.iter().copied())
.chain(motion_hooks.iter().copied())
.chain(chat_hooks.iter().copied())
.chain(mob_hooks.iter().copied())
.collect();
let mut names = BTreeSet::new();
let mut contracts = BTreeSet::new();
@@ -11100,6 +11103,8 @@ mod tests {
} else if breath_hooks.contains(name) {
// breathing.rs compiles into horizon-client-lib too.
format!("horizon_client_lib::breathing::native_{name}")
} else if mob_hooks.contains(name) {
format!("horizon_client_lib::mobs::ai::native_{name}")
} else if level_hooks.contains(name) {
format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}")
} else {
+1
View File
@@ -3625,6 +3625,7 @@ pub(crate) mod tests {
living_drown: |_, _, _, _, _, _| {},
living_fall: |_, _, _, _, _| {},
living_jump: |_, _| {},
register_goals: |_| {},
ingredient: |_, _, _| {},
ingredient_test: |_, _, _, _| {},
});
+16
View File
@@ -428,6 +428,7 @@ pub fn register(m: &mut Mob) {
Family::Panda => super::panda::register(m),
Family::PolarBear => super::polar_bear::register(m),
}
native_register_goals(m);
// FloatGoal's constructor lets the navigation float, as do the
// constructors of these brain-driven and flying mobs. A floater rises
// 0.04 a tick in Mob.jumpInLiquid, the others 0.3 (the bounce of #542).
@@ -445,6 +446,21 @@ pub fn register(m: &mut Mob) {
m.ai.nav.can_float = true;
}
}
/// A new mob's goals (`Mob.registerGoals`): `mob.ai.goals` (vanilla's
/// `goalSelector`) and `mob.ai.targets` (`targetSelector`) hold the goals of
/// its species, which for a mod's type is its template's. Clear them to
/// replace them, as a class whose `registerGoals` skips `super`'s, or add
/// yours with `GoalSelector::add(priority, goal)`. Each mob gets its own
/// goals, so a goal's fields are that mob's state while it lives (for state
/// that is saved, see attachments on `Holder::Entity(mob.id)`). Asked for
/// every mob Horizon makes, a client's copies of a server's mobs included,
/// before its saved data is read; goals tick only where the mob's AI runs.
/// `goals::Float` keeps the navigation floating, as FloatGoal's
/// constructor does. API @API:register_goals@.
#[inline(never)]
pub fn native_register_goals(mob: &mut Mob) {
crate::native_client_hooks::register_goals(mob);
}
/// Mob.tick additions that run before the entity tick (cube/creeper fuses, puffing).
fn pre_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.species() == Species::SulfurCube {
+2
View File
@@ -37,6 +37,8 @@ pub use crate::hud_layout::{GuiDraw, HealthHud, HealthInputs};
pub use crate::items::ItemStack;
pub use crate::merchant::MerchantOffer;
pub use crate::mesher::TintSource;
pub use crate::mobs::goals::{Cx, Goal, GoalSelector};
pub use crate::mobs::{Mob, MobWorld};
pub use crate::music::{Music, MusicLayer};
pub use crate::particles::{ParticleOptions, ParticleType};
pub use crate::recipe_book::RecipeBookCategory;
@@ -12,6 +12,7 @@ pub struct NativeClientHooks {
pub living_drown: fn(u32, &str, &mut bool, &mut f32, i32, &mut bool),
pub living_fall: fn(u32, &str, &mut f64, &mut f32, &mut bool),
pub living_jump: fn(u32, &str),
pub register_goals: fn(&mut crate::mobs::Mob),
pub ingredient: fn(&str, &Map<String, Value>, &mut Vec<String>),
pub ingredient_test: fn(&str, &Map<String, Value>, &ItemStack, &mut bool),
}
@@ -103,6 +104,12 @@ pub(crate) fn living_jump(entity: u32, kind: &str) {
}
}
pub(crate) fn register_goals(mob: &mut crate::mobs::Mob) {
if let Some(hooks) = HOOKS.get() {
(hooks.register_goals)(mob);
}
}
pub(crate) fn ingredient(kind: &str, data: &Map<String, Value>, items: &mut Vec<String>) {
if let Some(hooks) = HOOKS.get() {
(hooks.ingredient)(kind, data, items);
@@ -62,6 +62,7 @@ mod tests {
pub(super) struct MerchantOffer;
pub(super) struct ModFluidTanks;
pub(super) struct ModEnergyBuffer;
pub(super) struct Mob;
pub(super) struct LootStack;
pub(super) struct NativeLootContext<'a>(pub &'a ());
pub(super) struct NativeDataMapType;
+1 -1
View File
@@ -178,7 +178,7 @@ using the "Shape" column. What a content pack needs:
| Accessory slots (what Curios does) | — | Curios users (6 require it, more use it optionally) | **0.4** | built-in slots API |
| Mod networking between its client and server halves | — | machines and up | **0.4** | `net` interface |
| Custom recipe types: machine recipes with their own JSON | — | machines and up | **0.4** | recipe-type registry: the mod declares its JSON schema and a display layout for the recipe viewer |
| Entities: types, attributes, AI goals and brains, spawning | — | deep (87) | 0.5 | `entity` interface, AI in the mod with vanilla goals reusable; thrown item projectiles, and mobs of a zombie's or a villager's class with their own attributes, are already data in 0.2 ([mods.md](mods.md)) |
| Entities: types, attributes, AI goals and brains, spawning | — | deep (87) | 0.5 | `entity` interface, AI in the mod with vanilla goals reusable; thrown item projectiles, and mobs of a zombie's or a villager's class with their own attributes, are already data in 0.2 ([mods.md](mods.md)); a native mod gives a type its own goals with `register_goals` ([native-mods.md](native-mods.md#mob-goals)) |
| Custom renderers and animated models (block-entity, entity, item; GeckoLib's format) | — | deep | 0.5 | data models (Bedrock geometry + animations), plus a budgeted draw-list interface for the rest |
| Dimensions: dimension types, levels, chunk generators | — | about 10 (Twilight Forest, Aether, Ad Astra, Undergarden, ...) | 0.5 | JSON part with worldgen; custom generators as mod code |
| New hooks into vanilla behaviour (what Mixins do) | — | deep | as asked | each becomes a named hook, added for everyone |
+3 -2
View File
@@ -332,8 +332,9 @@ behaviour, with its own attributes. It saves what its template's class saves und
table is its own, `<id>:entities/<name>` (vanilla's `EntityType.getDefaultLootTable`). It is not in vanilla's entity type
tags (`#minecraft:undead`, `#minecraft:zombies`...) unless the mod adds it. Its row in `entity-types.json` is its
template's, with `template` added and its attributes in place of the template's. Unit tests check all this; it has
not yet been tried in a running game. Not yet: its own model or texture (a native mod draws one with
`native_mob_submit`), and its own goals.
not yet been tried in a running game. Not yet as data: its own model or texture (a native mod draws one with
`native_mob_submit`) and its own goals (a native mod gives it them with
[`register_goals`](native-mods.md#mob-goals)).
#### Menu types
+54
View File
@@ -2577,6 +2577,60 @@ place; `native_server_tick` does not define one, so name the dimension and expec
- While the game ticks its mobs they are out of the level: from a hook inside that tick, a spawned mob joins after the tick, and the others
read as gone.
### Mob goals
**`horizon_client_lib::mobs::ai::native_register_goals(mob)`** (vanilla's `Mob.registerGoals`; a trait mod's
`Hooks::register_goals(event)`, with `event.mob`) is asked for every new mob, after its species added its goals and before its
saved data is read. `mob.ai.goals` is vanilla's `goalSelector`, `mob.ai.targets` its `targetSelector`. A mod's living type
([mods.md](mods.md)) comes with its template's goals, a zombie's attacks, strolls and targets: clear them to replace them, as a
class whose `registerGoals` skips `super`'s, or add to them. A villager acts through its brain, which goals don't replace:
a type from the villager template still works, sleeps and trades as a villager, next to the goals a mod adds. A goal is a
Rust type implementing
`horizon_client::mobs::goals::Goal` (a bundle compiles into the game, so nothing is loaded at run time): its `flags` (`MOVE`,
`LOOK`, `JUMP`, `TARGET`), `can_use`, `can_continue`, `start`, `tick` and `stop` run by priority and flag as vanilla's
`GoalSelector` runs them. The vanilla goals in that module (`Float`, `Stroll`, `LookAtPlayer`, `MeleeAttack`, `NearestTarget`
and the rest) are there to reuse.
```rust
use horizon_client::mobs::{Mob, goals::{self, Cx, Goal}};
use horizon_client::mod_api::{Hooks, RegisterGoalsEvent};
/// Walks the mob back to its post when it is more than 4 blocks away.
#[derive(Clone, Debug)]
struct ToPost { post: [f64; 3] }
impl Goal for ToPost {
fn flags(&self) -> u8 { goals::MOVE }
fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
let [x, y, z] = self.post;
let (dx, dy, dz) = (m.position.x - x, m.position.y - y, m.position.z - z);
dx * dx + dy * dy + dz * dz > 16.0
}
fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool { !m.ai.nav.is_done() }
fn start(&mut self, m: &mut Mob, cx: &mut Cx) { m.nav_move_to(cx.w, self.post, 1.0); }
fn box_clone(&self) -> Box<dyn Goal> { Box::new(self.clone()) }
}
struct Guards;
impl Hooks for Guards {
fn register_goals(&self, event: &mut RegisterGoalsEvent<'_>) {
if event.mob.kind.id == "example:guard" {
let ai = &mut event.mob.ai;
ai.goals.clear();
ai.targets.clear();
ai.goals.add(0, goals::Float);
ai.goals.add(1, ToPost { post: [0.5, 64.0, 0.5] });
}
}
}
```
From a goal, `mob.nav_move_to(cx.w, [x, y, z], speed)` walks the mob with the engine's pathfinding (`create_path_to` and
`nav_move_to_path` to check a path first), and `mob.ai.mov.set_wanted(x, y, z, speed)` steps it toward a point, for a mod with
pathfinding of its own. `goals::Float` keeps the navigation floating, as FloatGoal's constructor does. Each mob gets its own
goals, so a goal's fields are that mob's state while it lives; state that is saved goes in an attachment on
`Holder::Entity(mob.id)` ([Server attachments](#server-attachments)), from a hook with a host such as `native_entity_tick_pre`.
The hook is asked on a client too, for its copies of a server's mobs; goals tick only where the mob's AI runs.
### Loot tables
Changing loot a mod didn't make, as NeoForge's `LootTableLoadEvent` and global loot modifiers and Fabric's
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "register_goals",
"target": "horizon_client_lib::mobs::ai::native_register_goals",
"neoforge": "Mob.registerGoals",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "a new mob's goals (`native_register_goals`, `Mob.registerGoals`).",
"doc_summary": "a new mob's goals (`native_register_goals(mob)`, vanilla's `Mob.registerGoals`): `mob.ai.goals` and\n`mob.ai.targets` hold its species' goals, a mod type's template's; a mod clears them to replace them or adds its own\n`Goal`s, which steer the mob with its navigation (`nav_move_to`, `create_path_to`) and controls. Bundles need\n\"hooks\": @API:register_goals@.",
"comments": ["crates/horizon-client/src/mobs/ai.rs"]
}