Merge pull request #10304 from MSedgeMC/fix/uuid-bridge

Entity saves: a UUID bridge for references to a mob
This commit is contained in:
Kacper authored and GitHub committed 2026-11-06 09:17:49 +01:00
commit 1bd640668a
11 files changed
+252 -66

No files matched your search

@@ -1450,7 +1450,13 @@ pub fn main() -> horizon_client::Result<()> {
else {
continue;
};
let tag = horizon_client::entity_nbt::mob_to_nbt(&mobs[i], [0; 4], None, None);
let tag = horizon_client::entity_nbt::mob_to_nbt(
&mobs[i],
[0; 4],
None,
None,
|_| None,
);
let flower = horizon_client::mobs::bee::get(&mobs[i]).and_then(|b| b.flower);
let mut honey = Vec::new();
let stored = {
@@ -355,7 +355,7 @@ pub fn main() -> horizon_client::Result<()> {
}
let mob =
entity_nbt::mob_from_nbt(&tag, &types, 1, None).ok_or("unreadable villager")?;
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None, None);
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None, None, |_| None);
writeln!(out, "{name} snbt {text}")?;
let mut kept = horizon_world::nbt::Compound::new();
for key in [
+202 -50
View File
@@ -171,6 +171,39 @@ fn player_uuid() -> [i32; 4] {
u as u32 as i32,
]
}
/// The UUID a save writes for a reference to `who`. A mob is only an id here:
/// its UUID lives in the game layer (`EntityMemory.uuids`), which is what
/// `uuid_of` answers from. When it answers `None` (the mob is no longer in the
/// level) nothing is written, rather than a made-up UUID that would name nothing
/// when the save is read back. The reference comes back with [`who_by_uuid`].
fn who_uuid(who: Who, uuid_of: &mut impl FnMut(u32) -> Option<[i32; 4]>) -> Option<[i32; 4]> {
match who {
Who::Player => Some(player_uuid()),
Who::Mob(id) => uuid_of(id),
}
}
/// The entity a stored UUID names, while it is in the level: `player` (the
/// player's own UUID, when one is in the level), or the mob `uuids` gives that
/// UUID to. `uuids` is the game layer's id -> UUID map, so this is the read half
/// of the pair [`who_uuid`] and `mob_to_nbt`'s `uuid_of` form. A UUID nothing
/// loaded has, or one naming a kind `Who` cannot carry (an item, a projectile, a
/// guest), is `None` - never some other entity as a stand-in.
pub fn who_by_uuid(
uuids: &HashMap<u32, [i32; 4]>,
mobs: &[Mob],
player: Option<[i32; 4]>,
uuid: [i32; 4],
) -> Option<Who> {
if player == Some(uuid) {
return Some(Who::Player);
}
// Only a loaded mob: the map also holds projectiles, vehicles and items,
// whose ids mean nothing to `Who`.
uuids
.iter()
.find(|(id, u)| **u == uuid && mobs.iter().any(|m| m.id == **id))
.map(|(id, _)| Who::Mob(*id))
}
/// `ResolvableProfile.CODEC`: the name, id and properties of a profile, then the
/// skin's texture ids and model (`PlayerSkin.Patch`: "texture", "cape", "elytra", "model").
fn profile_nbt(p: &horizon_protocol::entity_data::Profile) -> Tag {
@@ -344,11 +377,17 @@ fn is_air(registry: &BlockRegistry, state: BlockStateId) -> bool {
/// state (an enderman's `carriedBlockState`): without one, a caller that has no
/// resources (unit tests, structure templates) keeps whatever the loaded
/// compound had for it.
///
/// `uuid_of` is how a field that names another entity by UUID (a mob's
/// `angry_at`) is written: it answers the UUID of a mob by its id, from the game
/// layer's map, and `None` for one the level no longer has. A caller with no
/// such map passes `|_| None` and writes no reference.
pub fn mob_to_nbt(
m: &Mob,
uuid: [i32; 4],
raw: Option<&Tag>,
registry: Option<&BlockRegistry>,
mut uuid_of: impl FnMut(u32) -> Option<[i32; 4]>,
) -> Tag {
use crate::mobs::Species as S;
let base = Base {
@@ -430,8 +469,8 @@ pub fn mob_to_nbt(
-1
}),
);
if m.extra.angry_at == Some(crate::mobs::Who::Player) {
put(&mut tag, "angry_at", Tag::IntArray(player_uuid().to_vec()));
if let Some(uuid) = m.extra.angry_at.and_then(|who| who_uuid(who, &mut uuid_of)) {
put(&mut tag, "angry_at", Tag::IntArray(uuid.to_vec()));
}
}
// `Mannequin.addAdditionalSaveData`: the profile whose skin it wears, whether it
@@ -3862,11 +3901,11 @@ mod tests {
{
map.insert("Age".into(), Tag::Int(age));
}
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
assert_eq!(saved.get("Age"), expected.as_ref(), "{species:?}, {age:?}");
assert_eq!(saved.get("h228_control"), Some(&Tag::Int(42)));
}
let saved = mob_to_nbt(&mob, [0; 4], None, None);
let saved = mob_to_nbt(&mob, [0; 4], None, None, |_| None);
assert_eq!(
saved.get("Age"),
expected.as_ref(),
@@ -3897,7 +3936,7 @@ mod tests {
("audit:unrelated", Tag::Int(7)),
]);
for base in [None, Some(&raw)] {
let saved = mob_to_nbt(&mob, [0; 4], base, None);
let saved = mob_to_nbt(&mob, [0; 4], base, None, |_| None);
for (key, expected) in [
("Age", Tag::Int(-123)),
("ForcedAge", Tag::Int(19)),
@@ -3957,7 +3996,7 @@ mod tests {
Some("minecraft:diamond_horse_armor")
);
assert_eq!(m.extra.head.as_deref(), Some("minecraft:iron_helmet"));
let saved = mob_to_nbt(&m, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&m, [0; 4], Some(&raw), None, |_| None);
let eq = saved.get("equipment").unwrap();
let id = |key: &str| {
eq.get(key)
@@ -4051,7 +4090,7 @@ mod tests {
1
);
mob.max_swell = 7;
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
assert_eq!(saved.get("Fuse"), Some(&Tag::Short(7)));
}
}
@@ -4584,14 +4623,14 @@ mod tests {
// Query snapshots and saves both write the live field as an int,
// regardless of whether there is an old compound to preserve.
for old in [None, Some(&raw)] {
let mut saved = mob_to_nbt(&mob, [0, 0, 0, 1], old, None);
let mut saved = mob_to_nbt(&mob, [0, 0, 0, 1], old, None, |_| None);
assert_eq!(saved.get("InLove"), Some(&Tag::Int(expected)));
if let Tag::Compound(map) = &mut saved {
map.remove(&"InLove".into());
}
let cleared = mob_from_nbt(&saved, &types, 2, None).unwrap();
assert_eq!(cleared.extra.in_love, 0);
let saved = mob_to_nbt(&cleared, [0, 0, 0, 1], Some(&saved), None);
let saved = mob_to_nbt(&cleared, [0, 0, 0, 1], Some(&saved), None, |_| None);
assert_eq!(saved.get("InLove"), Some(&Tag::Int(0)));
}
}
@@ -5173,7 +5212,7 @@ mod axolotl_nbt_tests {
ty.species = crate::mobs::Species::Axolotl;
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
restore_axolotl_memories(&mut mob, &raw);
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
let entry = saved
.get("Brain")
.and_then(|b| b.get("memories"))
@@ -5223,7 +5262,7 @@ mod axolotl_nbt_tests {
let m = mob_from_nbt(&input, &types, 1, None).unwrap();
let expected = kind == "tadpole" || value.unwrap_or(false);
for raw in [None, Some(&input)] {
let saved = mob_to_nbt(&m, [1; 4], raw, None);
let saved = mob_to_nbt(&m, [1; 4], raw, None, |_| None);
assert_eq!(
saved.get("FromBucket"),
Some(&Tag::Byte(i8::from(expected))),
@@ -5235,7 +5274,7 @@ mod axolotl_nbt_tests {
}
let pig = Mob::new(1, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
assert!(
mob_to_nbt(&pig, [1; 4], None, None)
mob_to_nbt(&pig, [1; 4], None, None, |_| None)
.get("FromBucket")
.is_none()
);
@@ -5277,7 +5316,7 @@ mod axolotl_nbt_tests {
Tag::compound([("test:unrelated", Tag::compound([("value", Tag::Int(9))]))]),
)]),
)]);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None, |_| None);
let memories = tag.get("Brain").unwrap().get("memories").unwrap();
assert!(memories.get("test:unrelated").is_some());
assert_eq!(
@@ -5395,7 +5434,7 @@ mod axolotl_nbt_tests {
crate::mobs::animal_brain::set_hunting_cooldown(&mut m, 0);
assert!(bucket_data(&m).get("HuntingCooldown").is_none());
let saved = mob_to_nbt(&m, [1, 2, 3, 4], Some(&tag), None);
let saved = mob_to_nbt(&m, [1, 2, 3, 4], Some(&tag), None, |_| None);
assert!(
saved
.get("Brain")
@@ -5433,7 +5472,7 @@ mod sheep_nbt_tests {
// Summoned white, then dyed red and sheared: the save has it now.
let raw = Tag::compound([("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))]);
(m.extra.color, m.extra.sheared) = (14, true);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None, |_| None);
assert_eq!(
(number(tag.get("Color")), number(tag.get("Sheared"))),
(Some(14), Some(1))
@@ -5472,13 +5511,13 @@ mod sheep_nbt_tests {
let protected = mob_from_nbt(&raw, &types, 1, None).unwrap();
assert!(protected.extra.cannot_be_hunted);
assert!(!protected.seen(None).huntable);
let saved = mob_to_nbt(&protected, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&protected, [0; 4], Some(&raw), None, |_| None);
assert_eq!(saved.get("CannotBeHunted"), Some(&Tag::Byte(1)));
for raw in [hoglin(None), hoglin(Some(0))] {
let prey = mob_from_nbt(&raw, &types, 2, None).unwrap();
assert!(!prey.extra.cannot_be_hunted);
assert!(prey.seen(None).huntable);
let saved = mob_to_nbt(&prey, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&prey, [0; 4], Some(&raw), None, |_| None);
assert_eq!(saved.get("CannotBeHunted"), Some(&Tag::Byte(0)));
}
}
@@ -5511,7 +5550,7 @@ mod sheep_nbt_tests {
Some("mymod:entities/boss")
);
assert_eq!(m.extra.death_loot_seed, 42);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(
text(tag.get("DeathLootTable")).as_deref(),
Some("mymod:entities/boss")
@@ -5523,7 +5562,7 @@ mod sheep_nbt_tests {
("Pos", doubles(&[0.0, 64.0, 0.0])),
]);
let plain = mob_from_nbt(&plain, &types, 3, None).unwrap();
let tag = mob_to_nbt(&plain, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&plain, [1, 2, 3, 4], None, None, |_| None);
assert!(tag.get("DeathLootTable").is_none() && tag.get("DeathLootTableSeed").is_none());
}
@@ -5549,7 +5588,7 @@ mod sheep_nbt_tests {
0.0,
);
m.extra.more_carrot_ticks = 33;
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(number(tag.get("MoreCarrotTicks")), Some(33));
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert_eq!(back.extra.more_carrot_ticks, 33);
@@ -5576,10 +5615,10 @@ mod sheep_nbt_tests {
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(number(tag.get("Trusting")), Some(0));
m.extra.trusting = true;
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(number(tag.get("Trusting")), Some(1));
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert!(back.extra.trusting);
@@ -5608,7 +5647,7 @@ mod sheep_nbt_tests {
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
let save = |m: &Mob| mob_to_nbt(m, [1, 2, 3, 4], None, None);
let save = |m: &Mob| mob_to_nbt(m, [1, 2, 3, 4], None, None, |_| None);
// The default description is not written; it comes back.
let tag = save(&m);
assert!(tag.get("description").is_none() && tag.get("hide_description").is_none());
@@ -5659,7 +5698,7 @@ mod sheep_nbt_tests {
)]);
let mut m = Mob::new(1, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
m.add_effect(crate::brewing::effect("testfx:glow").unwrap().id, 200, 2);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
let Some(Tag::List(saved)) = tag.get("active_effects") else {
panic!("no active_effects");
};
@@ -5690,7 +5729,7 @@ mod sheep_nbt_tests {
200,
2,
);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
let Some(Tag::List(saved)) = tag.get("active_effects") else {
panic!("no active_effects");
};
@@ -5721,7 +5760,7 @@ mod sheep_nbt_tests {
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(
text(tag.get("variant")).as_deref(),
Some("minecraft:temperate")
@@ -5732,7 +5771,7 @@ mod sheep_nbt_tests {
);
// Spawned in a warm biome with the mini sounds.
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
assert_eq!(
text(tag.get("sound_variant")).as_deref(),
@@ -5747,7 +5786,7 @@ mod sheep_nbt_tests {
0.0,
);
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
let back = mob_from_nbt(&tag, &types, 4, None).unwrap();
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
@@ -5796,7 +5835,7 @@ mod sheep_nbt_tests {
Some(Tag::compound([("Age", Tag::Int(-100))])),
Some(Tag::compound([("Age", Tag::Int(100))])),
] {
let saved = mob_to_nbt(&mob, [0; 4], raw.as_ref(), None);
let saved = mob_to_nbt(&mob, [0; 4], raw.as_ref(), None, |_| None);
assert_eq!(
saved.get("Age"),
expected.as_ref(),
@@ -5827,7 +5866,7 @@ mod sheep_nbt_tests {
]);
let m = mob_from_nbt(&saved, &types, 1, None).unwrap();
assert_eq!(m.extra.age, -21359);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(number(tag.get("Age")), Some(-21359));
assert_eq!(number(tag.get("AgeLocked")), Some(0));
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
@@ -5860,7 +5899,7 @@ mod sheep_nbt_tests {
assert_eq!(m.extra.counter, 2400);
m.extra.flag = true;
m.extra.counter = 123;
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(number(tag.get("Moistness")), Some(123));
assert_eq!(number(tag.get("GotFish")), Some(1));
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
@@ -5901,7 +5940,7 @@ mod sheep_nbt_tests {
);
m.extra.ints[5] = 48000;
m.extra.point = Some([12.0, 65.0, -30.0]);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(number(tag.get("DespawnDelay")), Some(48000));
let Some(Tag::IntArray(target)) = tag.get("wander_target") else {
panic!("wander_target missing");
@@ -5939,7 +5978,7 @@ mod sheep_nbt_tests {
.unwrap();
let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
let round_trip = |m: &Mob| {
let tag = mob_to_nbt(m, [1, 2, 3, 4], None, Some(&registry));
let tag = mob_to_nbt(m, [1, 2, 3, 4], None, Some(&registry), |_| None);
(mob_from_nbt(&tag, &types, 2, Some(&registry)).unwrap(), tag)
};
// A save with nothing but where the mob stands.
@@ -6269,7 +6308,7 @@ mod item_component_tests {
);
// The save keeps the packed `Variant` int and gains no `variant` name,
// which only `VariantUtils.writeVariant` species store.
let tag = mob_to_nbt(&horse, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&horse, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(tag.get("variant"), None);
assert!(
matches!(tag.get("Variant"), Some(Tag::Int(v)) if *v == 6 | 2 << 8),
@@ -6327,7 +6366,8 @@ mod item_component_tests {
else {
return m;
};
let tag = crate::data_cmd::merged(&mob_to_nbt(&m, [1, 2, 3, 4], None, None), data);
let tag =
crate::data_cmd::merged(&mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None), data);
mob_from_nbt(&tag, &types, 2, None).unwrap()
};
// `entity_data={NoAI:1b}`: no id, so the egg's own type holds the data.
@@ -6811,7 +6851,7 @@ mod neutral_anger_tests {
m.extra.anger = 400;
m.extra.anger_end = 3000;
m.extra.angry_at = Some(crate::mobs::Who::Player);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
assert!(tag.get("angry_at").is_some());
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
@@ -6819,7 +6859,7 @@ mod neutral_anger_tests {
assert_eq!(back.extra.angry_at, Some(crate::mobs::Who::Player));
// A mob that is no NeutralMob writes no anger.
let pig = Mob::new(3, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None, None);
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None, None, |_| None);
assert!(tag.get("anger_end_time").is_none());
// The anger that is left when the mob comes back.
assert_eq!(crate::mobs::anger_left(3000, 1000), 2000);
@@ -6827,6 +6867,118 @@ mod neutral_anger_tests {
}
}
#[cfg(test)]
mod uuid_bridge_tests {
use super::*;
use crate::mobs::{Species, sensor_scan_tests::kind};
/// A wolf, whose anger is `NeutralMob`'s, so its save carries `angry_at`.
/// No resources are needed: only the species decides that.
fn wolf(id: u32) -> Mob {
let mut kind = (*kind()).clone();
kind.id = "minecraft:wolf".into();
kind.species = Species::Wolf;
let mut m = Mob::new(id, Arc::new(kind), Vec3::ZERO, 0.0);
m.extra.anger = 400;
m.extra.anger_end = 3000;
m
}
/// A mob's anger at another mob is saved as that mob's UUID, which the game
/// layer answers for (`Who` itself carries none).
#[test]
fn a_mob_anger_target_is_saved_as_its_uuid() {
let mut m = wolf(1);
m.extra.angry_at = Some(Who::Mob(7));
let target = [7, 0, 0, 3];
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |id| {
(id == 7).then_some(target)
});
assert_eq!(tag.get("angry_at"), Some(&Tag::IntArray(target.to_vec())));
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
}
/// A target the map does not know (a mob that left the level) is not named:
/// nothing is written rather than a made-up UUID no entity answers for.
#[test]
fn an_unknown_anger_target_writes_no_angry_at() {
let mut m = wolf(1);
m.extra.angry_at = Some(Who::Mob(7));
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(tag.get("angry_at"), None);
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
}
/// The player is not an id: its UUID needs no map from the caller.
#[test]
fn a_player_anger_target_needs_no_map() {
let mut m = wolf(1);
m.extra.angry_at = Some(Who::Player);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(
tag.get("angry_at"),
Some(&Tag::IntArray(player_uuid().to_vec()))
);
}
/// The round trip both deferred fixes need: a mob's UUID written through the
/// save seam reads back as that mob, not as another one.
#[test]
fn a_saved_mob_uuid_resolves_back_to_it() {
let mut wolf = wolf(1);
let pig = {
let mut kind = (*kind()).clone();
kind.id = "minecraft:pig".into();
kind.species = Species::Pig;
Mob::new(9, Arc::new(kind), Vec3::ZERO, 0.0)
};
let (wolf_uuid, pig_uuid) = ([1, 2, 3, 4], [9, 0, 0, 1]);
let uuids: HashMap<u32, [i32; 4]> = HashMap::from([(1, wolf_uuid), (9, pig_uuid)]);
wolf.extra.angry_at = Some(Who::Mob(pig.id));
let tag = mob_to_nbt(&wolf, wolf_uuid, None, None, |id| uuids.get(&id).copied());
let Some(Tag::IntArray(stored)) = tag.get("angry_at") else {
panic!("anger at a mob is saved as that mob's UUID");
};
assert_eq!(stored, &pig_uuid.to_vec());
let mobs = [wolf, pig];
assert_eq!(
who_by_uuid(&uuids, &mobs, None, pig_uuid),
Some(Who::Mob(9))
);
// The other loaded mob is not mistaken for it.
assert_eq!(
who_by_uuid(&uuids, &mobs, None, wolf_uuid),
Some(Who::Mob(1))
);
// The player is the other half of `Who`, kept apart from the id map.
let player = [5, 5, 5, 5];
assert_eq!(
who_by_uuid(&uuids, &mobs, Some(player), player),
Some(Who::Player)
);
}
/// A UUID nothing loaded has is no entity at all: not another mob, and not
/// the projectile whose id shares the map.
#[test]
fn a_uuid_no_loaded_mob_has_resolves_to_nothing() {
let wolf = wolf(1);
let uuids: HashMap<u32, [i32; 4]> =
HashMap::from([(1, [1, 2, 3, 4]), (1000, [5, 0, 0, 1])]);
let mobs = [wolf];
// Never loaded (it left, or the save is another world's).
assert_eq!(who_by_uuid(&uuids, &mobs, None, [9, 9, 9, 9]), None);
// Loaded, but not a mob: `Who::Mob` would mean an arrow's id.
assert_eq!(who_by_uuid(&uuids, &mobs, None, [5, 0, 0, 1]), None);
// Nothing loaded at all resolves to nothing either.
assert_eq!(who_by_uuid(&uuids, &[], None, [1, 2, 3, 4]), None);
assert_eq!(
who_by_uuid(&uuids, &mobs, None, [1, 2, 3, 4]),
Some(Who::Mob(1))
);
}
}
#[cfg(test)]
mod held_item_name_tests {
use super::*;
@@ -6850,7 +7002,7 @@ mod held_item_name_tests {
helmet.damage = 40;
helmet.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Hat".into()));
crate::mobs::finalize::set_item_stack(&mut mob, crate::mobs::finalize::HEAD, helmet);
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None, |_| None);
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
let worn = crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
assert_eq!(worn.damage, 40);
@@ -6886,7 +7038,7 @@ mod held_item_name_tests {
let mut sword = crate::items::ItemStack::new("minecraft:diamond_sword", 1);
sword.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Old Sword".into()));
mob.extra.set_main_hand_stack(Some(sword));
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None, |_| None);
let mut loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
assert_eq!(
loaded
@@ -6900,7 +7052,7 @@ mod held_item_name_tests {
loaded
.extra
.set_main_hand(Some("minecraft:diamond_sword".into()));
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw), None);
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw), None, |_| None);
let restored = mob_from_nbt(&saved, &types, 3, None).unwrap();
assert_eq!(
restored.extra.main_hand.as_deref(),
@@ -7057,7 +7209,7 @@ mod axolotl_memory_ttl_tests {
ty.species = crate::mobs::Species::Axolotl;
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
restore_axolotl_memories(&mut mob, &raw);
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
let entry = memory(&saved, key).expect("loaded value must be present");
assert_eq!(entry.get("value"), Some(&Tag::Int(100)));
let expected = ttl.filter(|v| *v != i64::MAX).map(Tag::Long);
@@ -7070,7 +7222,7 @@ mod axolotl_memory_ttl_tests {
} else {
brain.mem.temptation_cooldown_ticks.set(99);
}
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None, |_| None);
let entry = memory(&saved, key).unwrap();
assert_eq!(entry.get("value"), Some(&Tag::Int(99)));
assert!(entry.get("ttl").is_none(), "stale raw TTL must not return");
@@ -7106,7 +7258,7 @@ mod generic_home_nbt_tests {
for raw in [None, Some(&raw)] {
for radius in [0, 5, -2, -1] {
m.ai.home = Some(([4, 5, 6], radius));
let saved = mob_to_nbt(&m, [0; 4], raw, None);
let saved = mob_to_nbt(&m, [0; 4], raw, None, |_| None);
if radius == -1 {
assert!(saved.get("home_radius").is_none());
assert!(saved.get("home_pos").is_none());
@@ -7116,7 +7268,7 @@ mod generic_home_nbt_tests {
}
}
m.ai.home = None;
let saved = mob_to_nbt(&m, [0; 4], raw, None);
let saved = mob_to_nbt(&m, [0; 4], raw, None, |_| None);
assert!(saved.get("home_radius").is_none());
assert!(saved.get("home_pos").is_none());
if raw.is_some() {
@@ -7209,7 +7361,7 @@ mod generic_home_nbt_tests {
for raw in [None, Some(&raw)] {
for home in [None, Some(([7, 8, 9], 5))] {
m.ai.home = home;
let saved = mob_to_nbt(&m, [0; 4], raw, None);
let saved = mob_to_nbt(&m, [0; 4], raw, None, |_| None);
assert_eq!(saved.get("home_pos"), Some(&Tag::IntArray(vec![4, 5, 6])));
assert_eq!(
saved.get("home_radius"),
@@ -7276,7 +7428,7 @@ mod piglin_live_anger_memory_tests {
for remaining in [600, 520] {
m.extra.anger = remaining;
for base in [None, Some(&raw)] {
let saved = mob_to_nbt(&m, [42, 43, 44, 45], base, None);
let saved = mob_to_nbt(&m, [42, 43, 44, 45], base, None, |_| None);
assert_eq!(
memory(&saved),
Some(&Tag::compound([
@@ -7298,7 +7450,7 @@ mod piglin_live_anger_memory_tests {
crate::mobs::piglin::set_guarded_anger_target(&mut m, Some([1, 2, 3, 4]));
m.extra.anger = 0;
m.ai.target = None;
let saved = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw()), None);
let saved = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw()), None, |_| None);
assert!(memory(&saved).is_none());
assert_unrelated(&saved);
}
@@ -7306,15 +7458,15 @@ mod piglin_live_anger_memory_tests {
#[test]
fn calm_loaded_and_missing_identity_states_are_distinct() {
let mut m = mob();
assert!(memory(&mob_to_nbt(&m, [42, 43, 44, 45], None, None)).is_none());
assert!(memory(&mob_to_nbt(&m, [42, 43, 44, 45], None, None, |_| None)).is_none());
let raw = raw();
// Loaded Brain semantics stay outside this live event projection.
let untouched = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw), None);
let untouched = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw), None, |_| None);
assert_eq!(memory(&untouched), memory(&raw));
crate::mobs::piglin::set_guarded_anger_target(&mut m, None);
assert_eq!(m.extra.anger, 600);
assert_eq!(m.ai.target, Some(Who::Player));
let saved = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw), None);
let saved = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw), None, |_| None);
assert!(memory(&saved).is_none());
assert_unrelated(&saved);
}
@@ -692,7 +692,9 @@ impl Game<'_> {
let uuid = self.persist.uuids.get(&m.id).copied().unwrap_or_default();
let raw = self.persist.raw.get(&m.id);
let registry = Some(self.pack.assets.registry);
let mut tag = horizon_client::entity_nbt::mob_to_nbt(m, uuid, raw, registry);
let mut tag = horizon_client::entity_nbt::mob_to_nbt(m, uuid, raw, registry, |other| {
self.persist.uuid_of(other)
});
let riders = passengers.iter().filter_map(|r| r.nbt.clone()).collect();
super::persist::add_passengers(&mut tag, riders);
tag
+4 -2
View File
@@ -674,7 +674,8 @@ mod tests {
Mob::new(2, ghoul.clone(), at, 0.0),
);
assert_eq!(ghoul.health, 40.0);
let saved = |m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None);
let saved =
|m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None, |_| None);
let keys = |tag: &horizon_world::nbt::Tag| -> Vec<String> {
let horizon_world::nbt::Tag::Compound(map) = tag else {
panic!("a mob saves a compound");
@@ -725,7 +726,8 @@ mod tests {
let trader = Mob::new(1, types["minecraft:wandering_trader"].clone(), at, 0.0);
let merchant = Mob::new(2, merchant.clone(), at, 0.0);
assert!(merchant.ai.goals.has("Float") && trader.ai.goals.has("Float"));
let saved = |m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None);
let saved =
|m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None, |_| None);
let keys = |tag: &Tag| -> Vec<String> {
let Tag::Compound(map) = tag else {
panic!("a mob saves a compound");
+26 -5
View File
@@ -52,6 +52,15 @@ pub struct EntityMemory {
/// Only filled while a mod is loaded.
pub(super) live: Vec<(u32, String, bool)>,
}
impl EntityMemory {
/// The UUID entity `id` was loaded or saved with: what a save writes for a
/// field naming that entity (`entity_nbt::mob_to_nbt`'s `uuid_of`). `None`
/// for one that has none yet, so no reference is written rather than a
/// made-up UUID nothing would answer for.
pub(super) fn uuid_of(&self, id: u32) -> Option<[i32; 4]> {
self.uuids.get(&id).copied()
}
}
/// The key that marks a compound queued with the chunk loads as a fresh add
/// (`PersistentEntitySectionManager.addNewEntity`: `/summon`, a portal
@@ -1154,6 +1163,7 @@ impl Game<'_> {
uuid,
self.persist.raw.get(&id),
Some(self.pack.assets.registry),
|other| self.persist.uuid_of(other),
);
if horizon_client::mobs::camel::is_camel(self.mobs[i].species())
&& let Tag::Compound(c) = &mut tag
@@ -1275,13 +1285,24 @@ impl Game<'_> {
join_saved_team(&mut self.score.board, &name, tag);
}
}
/// The entity a UUID read back from a save names, while it is in the level:
/// the player, or a mob loaded or saved with that UUID. `None` for nothing
/// loaded (an entity that is not here, or one `Who` cannot carry) - never
/// some other entity as a stand-in.
pub(super) fn who_by_uuid(&self, uuid: [i32; 4]) -> Option<Who> {
let player = if self.player.is_some() {
self.level.player_uuid
} else {
None
};
entity_nbt::who_by_uuid(&self.persist.uuids, &self.mobs, player, uuid)
}
/// The mob that was loaded or saved with a UUID.
pub(super) fn mob_by_uuid(&self, uuid: [i32; 4]) -> Option<u32> {
self.persist
.uuids
.iter()
.find(|(id, u)| **u == uuid && self.mobs.iter().any(|m| m.id == **id))
.map(|(id, _)| *id)
match self.who_by_uuid(uuid) {
Some(Who::Mob(id)) => Some(id),
_ => None,
}
}
/// A mob's UUID, made up for new ones.
pub(super) fn mob_uuid(&mut self, id: u32) -> [i32; 4] {
+4 -2
View File
@@ -701,11 +701,13 @@ mod tests {
assert_eq!(m.extra.owner, owner);
assert_eq!(m.extra.sitting, expected, "{id} {owner:?} {sitting:?}");
// No raw compound: this must come from the modeled flag.
let saved = crate::entity_nbt::mob_to_nbt(&m, [5, 6, 7, 8], None, None);
let saved =
crate::entity_nbt::mob_to_nbt(&m, [5, 6, 7, 8], None, None, |_| None);
assert_eq!(saved.get("Sitting"), Some(&Tag::Byte(i8::from(expected))));
// A stale raw flag must not override a later model change.
m.extra.sitting = !expected;
let saved = crate::entity_nbt::mob_to_nbt(&m, [5, 6, 7, 8], Some(&raw), None);
let saved =
crate::entity_nbt::mob_to_nbt(&m, [5, 6, 7, 8], Some(&raw), None, |_| None);
assert_eq!(saved.get("Sitting"), Some(&Tag::Byte(i8::from(!expected))));
}
}
+1 -1
View File
@@ -566,7 +566,7 @@ mod tests {
// Animal.addAdditionalSaveData / readAdditionalSaveData: "InLove" (#1396).
let mut m = rabbit();
m.extra.in_love = 570;
let tag = crate::entity_nbt::mob_to_nbt(&m, [0, 0, 0, 1], None, None);
let tag = crate::entity_nbt::mob_to_nbt(&m, [0, 0, 0, 1], None, None, |_| None);
assert_eq!(tag.get("InLove"), Some(&horizon_world::nbt::Tag::Int(570)));
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
let back = crate::entity_nbt::mob_from_nbt(&tag, &types, 2, None).unwrap();
+1 -1
View File
@@ -757,7 +757,7 @@ mod tests {
.is_some_and(|d| d.ominous_banner)
);
assert!(is_captain(&m));
let saved = crate::entity_nbt::mob_to_nbt(&m, [0; 4], None, None);
let saved = crate::entity_nbt::mob_to_nbt(&m, [0; 4], None, None, |_| None);
let chances = saved.get("drop_chances").unwrap();
assert_eq!(
chances.get("head"),
+1 -1
View File
@@ -1003,7 +1003,7 @@ mod tests {
assert_eq!(map.get(&"sleeping_pos".into()), expected.as_ref());
assert_eq!(map.get(&"h232_control".into()), Some(&Tag::Int(42)));
}
let saved = crate::entity_nbt::mob_to_nbt(&m, [0; 4], None, None);
let saved = crate::entity_nbt::mob_to_nbt(&m, [0; 4], None, None, |_| None);
assert_eq!(saved.get("sleeping_pos"), expected.as_ref());
}
}
@@ -161,7 +161,8 @@ fn run<R: RandomSource>(
fn tree(mobs: &[(Mob, Option<Tag>)], i: usize, dimension: &str) -> Compound {
let (m, raw) = &mobs[i];
// No block registry here: a carried block stays as the template had it.
let Tag::Compound(mut c) = entity_nbt::mob_to_nbt(m, [0; 4], raw.as_ref(), None) else {
let saved = entity_nbt::mob_to_nbt(m, [0; 4], raw.as_ref(), None, |_| None);
let Tag::Compound(mut c) = saved else {
return Compound::new();
};
c.remove(&"UUID".into());