Merge pull request #10383 from voidsipp/fix/10369-map-x-offscreen

Keep treasure map targets when carrying undecorated copies
This commit is contained in:
rdbt authored and GitHub committed 2026-11-07 12:41:08 +00:00
commit 4ea806a2db
6 files changed
+153 -59

No files matched your search

@@ -356,7 +356,7 @@ mod tests {
y,
rot,
name: None,
key: Key::Item,
key: Key::Remote,
};
// The centre, facing north: 8 map pixels wide, nudged by (-0.5, 0.5).
close(
@@ -726,6 +726,12 @@ impl Game<'_> {
);
out.push('\n');
}
"map_carried" => {
self.harness_owner(|g| {
g.tick_maps();
Ok(())
})?;
}
"craft_menu_click" | "craft_menu_state" | "craft_menu_close" => {
let owner = self.serving.as_ref().ok_or("no server")?.owner;
let state = self.in_owner_level(|g| -> Result<Value> {
@@ -824,10 +830,21 @@ impl Game<'_> {
let id = op["id"].as_i64().ok_or("command harness: map id")? as i32;
let value = self.harness_owner(|g| {
Ok(match g.map(id) {
Some(m) => format!(
"scale={},locked={},center={},{}",
m.scale, m.locked, m.center[0], m.center[1]
),
Some(m) => {
let mut value = format!(
"scale={},locked={},center={},{}",
m.scale, m.locked, m.center[0], m.center[1]
);
if op["red_x"].as_bool().unwrap_or(false) {
let count = m
.decorations
.iter()
.filter(|d| d.sprite == "map/decorations/red_x")
.count();
value.push_str(&format!(",red_x={count}"));
}
value
}
None => "missing".to_owned(),
})
})?;
+79 -51
View File
@@ -11,19 +11,20 @@ use horizon_client::{
items::ItemStack,
mesher::Vertex,
};
use horizon_world::nbt::Tag;
use horizon_world::nbt::{JavaString, Tag};
use std::collections::HashMap;
/// What a decoration is filed under in `MapItemSavedData.decorations` (a
/// `LinkedHashMap<String, MapDecoration>`): a player's marker by their
/// connection, the banner at a position (`MapBanner.getId`), a framed map's
/// frame by its entity (`getFrameKey`).
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Key {
Player(u32),
/// An item's `minecraft:map_decorations` (an explorer map's target), or a
/// decoration a server sent (`addClientSideDecorations`).
Item,
/// A named entry of an item's `minecraft:map_decorations`.
Item(JavaString),
/// A decoration a server sent (`addClientSideDecorations`).
Remote,
Banner([i32; 3]),
Frame(i32),
}
@@ -301,8 +302,8 @@ impl MapData {
self.holders.retain(|h| h.holds(now));
let holders = &self.holders;
let before = self.decorations.len();
self.decorations.retain(|d| match d.key {
Key::Player(w) => holders.iter().any(|h| h.who == w),
self.decorations.retain(|d| match &d.key {
Key::Player(w) => holders.iter().any(|h| h.who == *w),
_ => true,
});
if self.decorations.len() != before {
@@ -365,7 +366,11 @@ impl MapData {
name,
key,
};
match self.decorations.iter().position(|d| d.key == key) {
match self
.decorations
.iter()
.position(|d| d.key == decoration.key)
{
Some(i) if self.decorations[i] == decoration => {}
Some(i) => {
self.decorations[i] = decoration;
@@ -377,6 +382,24 @@ impl MapData {
}
}
}
/// `tickCarriedBy` adds only missing component keys. Another carried
/// copy without those entries must not erase an explorer map's target.
fn add_item_decorations(&mut self, targets: Vec<StaticDecoration>, game_time: i64) {
for (name, sprite, x, z, rotation) in targets {
let key = Key::Item(name);
if !self.decorations.iter().any(|d| d.key == key) {
self.add_decoration(
sprite,
Some(game_time),
key,
x,
z,
f64::from(rotation),
None,
);
}
}
}
/// `MapItemSavedData.tickCarriedBy`'s check on every holder but `who`, the
/// player ticking: one wearing map invisibility gear has no marker.
fn hide_invisible(&mut self, who: u32) {
@@ -785,9 +808,11 @@ fn decoration_type(sprite: &str) -> Option<&'static str> {
.find(|(_, s)| *s == sprite)
.map(|(name, _)| *name)
}
/// `minecraft:map_decorations` of a map item: (sprite, x, z, rotation), in
/// the component's order.
fn static_decorations(stack: &ItemStack) -> Vec<(&'static str, f64, f64, f32)> {
/// A named `minecraft:map_decorations` entry: key, sprite, x, z, rotation.
type StaticDecoration = (JavaString, &'static str, f64, f64, f32);
/// `minecraft:map_decorations` of a map item, in the component's order.
fn static_decorations(stack: &ItemStack) -> Vec<StaticDecoration> {
let Some(Tag::Compound(entries)) = stack.data().and_then(|d| {
d.other
.iter()
@@ -803,13 +828,14 @@ fn static_decorations(stack: &ItemStack) -> Vec<(&'static str, f64, f64, f32)> {
_ => None,
};
entries
.values()
.filter_map(|e| {
.iter()
.filter_map(|(key, e)| {
let sprite = match e.get("type") {
Some(Tag::String(s)) => decoration_sprite(&s.to_string_lossy())?,
_ => return None,
};
Some((
key.clone(),
sprite,
number(e.get("x"))?,
number(e.get("z"))?,
@@ -908,7 +934,7 @@ impl Game<'_> {
.name
.as_ref()
.map(|t| horizon_client::entity_nbt::name_text(t).plain()),
key: Key::Item,
key: Key::Remote,
})
})
.collect()
@@ -1337,7 +1363,7 @@ impl Game<'_> {
.flatten()
.filter_map(|i| self.stack_in(i).and_then(map_id))
.collect();
let carried: Vec<(i32, Vec<(&'static str, f64, f64, f32)>)> = self
let carried: Vec<(i32, Vec<StaticDecoration>)> = self
.inventory
.iter()
.chain(std::iter::once(&self.offhand))
@@ -1345,7 +1371,7 @@ impl Game<'_> {
.filter_map(|s| Some((map_id(s)?, static_decorations(s))))
.collect();
let (yaw, dim) = (self.yaw, self.dimension_id());
let (nether, game_time) = (self.dim == super::Dim::Nether, self.world.game_time);
let game_time = self.world.game_time;
let invisible = self.map_invisibility_worn(&self.armor);
// The guest whose turn it is, else the host.
let me = self.lan_acting.unwrap_or(HOST);
@@ -1370,39 +1396,7 @@ impl Game<'_> {
m.remove_decoration(Key::Player(me));
}
m.hide_invisible(me);
// The item's MAP_DECORATIONS (an explorer map's target), added
// after the players as addDecoration would.
let s = (1 << m.scale) as f32;
let mut items: Vec<Decoration> = Vec::new();
for (sprite, x, z, rotation) in targets {
let dx = (x - f64::from(m.center[0])) as f32 / s;
let dz = (z - f64::from(m.center[1])) as f32 / s;
let inside = (-63.0..=63.0).contains(&dx) && (-63.0..=63.0).contains(&dz);
if !inside && !m.unlimited {
continue;
}
let d = Decoration {
sprite,
x: clamp_map_coordinate(dx),
y: clamp_map_coordinate(dz),
rot: decoration_rotation(nether, game_time, f64::from(rotation)),
name: None,
key: Key::Item,
};
if !items.contains(&d) {
items.push(d);
}
}
if !m
.decorations
.iter()
.filter(|d| d.key == Key::Item)
.eq(items.iter())
{
m.decorations.retain(|d| d.key != Key::Item);
m.decorations.extend(items);
m.decorations_changed();
}
m.add_item_decorations(targets, game_time);
}
for id in held {
self.update_map(id);
@@ -1965,7 +1959,7 @@ mod tests {
y: -30,
rot: 4,
name: None,
key: Key::Item,
key: Key::Remote,
};
assert_eq!(
decoration_pose(&vanilla, -0.02),
@@ -2018,13 +2012,47 @@ mod tests {
));
assert_eq!(map_id(&stack), Some(3));
let found = static_decorations(&stack);
assert_eq!(found, vec![("map/decorations/red_x", 120.0, -300.0, 180.0)]);
assert_eq!(
found,
vec![("+".into(), "map/decorations/red_x", 120.0, -300.0, 180.0)]
);
// calculateRotation(180): (byte) (188 * 16 / 360) = 8.
assert_eq!(decoration_rotation(false, 0, 180.0), 8);
assert_eq!(decoration_rotation(false, 0, -90.0), 12);
assert_eq!(clamp_map_coordinate(-70.0), -128);
assert_eq!(clamp_map_coordinate(10.25), 21);
}
#[test]
fn an_undecorated_copy_does_not_erase_the_treasure_target() {
let mut map = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
let mut treasure = ItemStack::new("minecraft:filled_map", 1);
treasure.data_mut().map_id = Some(7);
treasure.data_mut().other.push((
"minecraft:map_decorations".into(),
target_decoration("minecraft:red_x", [20, 40, 30]),
));
let mut copy = ItemStack::new("minecraft:filled_map", 1);
copy.data_mut().map_id = Some(7);
map.add_item_decorations(static_decorations(&treasure), 0);
map.add_item_decorations(static_decorations(&copy), 0);
assert_eq!(map.decorations.len(), 1);
assert_eq!(map.decorations[0].sprite, "map/decorations/red_x");
assert_eq!((map.decorations[0].x, map.decorations[0].y), (40, 60));
}
#[test]
fn named_map_targets_are_added_once_without_replacing_other_targets() {
let mut map = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
let target = |key: JavaString, x| (key, "map/decorations/red_x", x, 30.0, 180.0);
let first_key = JavaString(vec![0xd800]);
let second_key = JavaString(vec![0xd801]);
map.add_item_decorations(vec![target(first_key.clone(), 20.0)], 0);
map.add_item_decorations(vec![target(first_key, 40.0), target(second_key, 25.0)], 1);
assert_eq!(map.decorations.len(), 2);
assert_eq!((map.decorations[0].x, map.decorations[1].x), (40, 50));
// Both entries are off the map: neither may remove an existing target.
map.add_item_decorations(vec![target("absent".into(), 200.0)], 2);
assert_eq!(map.decorations.len(), 2);
}
/// `ItemStack.useOn` / `MapItem.useOn`: in adventure mode, a map whose
/// `can_place_on` component includes the banner block toggles that banner's
@@ -925,6 +925,17 @@ public final class VanillaCommandExecOracle {
throw new IllegalStateException(e);
}
}
case "map_carried" -> {
// The harness has no player network listener; invoke the
// real map update normally reached from its player tick.
for (int i = 0; i < player.getInventory().getContainerSize(); i++) {
var stack = player.getInventory().getItem(i);
var id = stack.get(net.minecraft.core.component.DataComponents.MAP_ID);
if (id == null) continue;
var map = net.minecraft.world.item.MapItem.getSavedData(id, player.level());
if (map != null) map.tickCarriedBy(player, stack, null);
}
}
case "craft_menu_click" -> {
if (!(player.containerMenu instanceof net.minecraft.world.inventory.CraftingMenu))
throw new IllegalStateException("craft fixture: crafting menu not open");
@@ -977,6 +988,12 @@ public final class VanillaCommandExecOracle {
new net.minecraft.world.level.saveddata.maps.MapId(id), player.level());
String value = map == null ? "missing" : "scale=" + map.scale + ",locked=" + map.locked
+ ",center=" + map.centerX + "," + map.centerZ;
if (map != null && op.has("red_x")) {
int targets = 0;
for (var decoration : map.getDecorations())
if (decoration.type().is(net.minecraft.world.level.saveddata.maps.MapDecorationTypes.RED_X)) targets++;
value += ",red_x=" + targets;
}
JsonObject record = new JsonObject();
record.addProperty("cmd", "#map_state " + id);
JsonArray messages = new JsonArray();
+4 -3
View File
@@ -5855,7 +5855,7 @@ def command_zombie_reinforcements(once=False, raw=False, legacy=False):
ISOLATED_REINFORCEMENTS = frozenset({'command zombie reinforcements', 'command reinforcement once'})
# These overlays replace built-in recipes, never beside the corpus's fixed vanilla recipe-count controls.
ISOLATED_RECIPES = frozenset({'crafting recipe priority', 'crafting base recipe control', 'transmute result patches'})
ISOLATED_FRAME_MAPS = frozenset({'frame live map detachment'})
ISOLATED_FRAME_MAPS = frozenset({'frame live map detachment', 'treasure map copies'})
ISOLATED_MAP_CRAFTING = frozenset({'craft map takes'})
ISOLATED_MACRO_TYPE = frozenset({'macro source type error'})
ISOLATED_PICKUP_SAVE = frozenset({'mob saved pickup loot'})
@@ -5882,7 +5882,7 @@ def groups():
except ImportError:
from mob_name_predicate_corpus import cases as mob_name_predicates
try:
from .frame_map_corpus import frame_maps
from .frame_map_corpus import frame_maps, treasure_map_copies
from .allay_damage_corpus import allay_player_damage
from .wolf_anger_corpus import groups as wolf_anger_groups
from .allay_healing_corpus import allay_healing
@@ -5893,7 +5893,7 @@ def groups():
from .gateway_passenger_corpus import gateway_passengers
from .item_merge_corpus import stack_limit_merges
except ImportError:
from frame_map_corpus import frame_maps
from frame_map_corpus import frame_maps, treasure_map_copies
from allay_damage_corpus import allay_player_damage
from wolf_anger_corpus import groups as wolf_anger_groups
from allay_healing_corpus import allay_healing
@@ -5913,6 +5913,7 @@ def groups():
('mob effect predicate view', mob_effect_predicates(cmd, setup)),
('mob custom name components', mob_name_predicates(cmd, setup)),
('frame live map detachment', frame_maps(cmd, setup)),
('treasure map copies', treasure_map_copies(cmd, setup)),
('craft map takes', craft_map_takes(cmd, setup)),
('allay player damage immunity', allay_player_damage(cmd, setup)),
*wolf_anger_groups(cmd, setup),
+31
View File
@@ -147,3 +147,34 @@ def frame_maps(cmd, setup):
return ops + setup('/kill @e[tag=frame_map_1]', '/kill @e[tag=frame_map_3]',
'/scoreboard objectives remove frame_maps', '/clear @s', '/gamemode creative',
'/forceload remove -32 -32 31 31', '/tick unfreeze')
def treasure_map_copies(cmd, setup):
"""A same-id copy without components must not remove a treasure map's X."""
ops = []
def run(command, result=1):
ops.append(cmd(command, expect_result=result))
def step():
ops.append({'op': 'map_carried'})
def state():
ops.append({'op': 'map_state', 'id': 0, 'red_x': True,
'expect_map': 'scale=0,locked=false,center=0,0,red_x=1'})
run('/tick freeze')
ops.extend(setup('/gamemode survival', '/clear @s', '/tp @s 4.5 100 4.5 0 0',
'/forceload add 0 0 15 15'))
ops.append({'op': 'settle', 'required_chunks': [[0, 0]]})
run('/item replace entity @s weapon.mainhand with map')
ops.append({'op': 'use_item'})
run('/execute if items entity @s weapon.mainhand filled_map[map_id=0]')
target = 'filled_map[map_id=0,map_decorations={"+":{type:"minecraft:red_x",x:20.0d,z:30.0d,rotation:180.0f}}]'
run('/item replace entity @s weapon.mainhand with ' + target)
step()
state()
run('/item replace entity @s hotbar.1 with filled_map[map_id=0]')
step()
state()
# Retain the target even when only the undecorated copy remains.
run('/item replace entity @s weapon.mainhand with air')
step()
state()
return ops