Merge pull request #5321 from sirrayi/fix/jukebox-hopper-disc

🤖 Agent: new head (author merged main) keeps the hopper→jukebox fix and jukebox_taken (song stops when a hopper takes the disc); verify passes on aa68b6653, merges cleanly with current main (no overlap in jukebox/machines/spill/save)
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-31 22:34:53 +01:00
commit a4873dec44
8 files changed
+110 -18

No files matched your search

@@ -291,11 +291,19 @@ pub struct Beacon {
}
#[derive(Clone, Debug, Default)]
pub struct Jukebox {
pub record: Option<ItemStack>,
/// `JukeboxBlockEntity`'s one slot (`ContainerSingleItem`), so a hopper or
/// a dropper reaches it as it reaches any container.
pub items: [Option<ItemStack>; 1],
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
pub playing: bool,
}
impl Jukebox {
/// `JukeboxBlockEntity.getTheItem`: the disc it holds.
pub fn record(&self) -> Option<&ItemStack> {
self.items[0].as_ref()
}
}
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
#[derive(Clone, Debug, Default)]
pub struct BrewingStand {
+23 -3
View File
@@ -421,7 +421,7 @@ impl BlockEntity {
Self::Furnace(f) => f.items.iter().flatten().cloned().collect(),
Self::Brewing(b) => b.items.iter().flatten().cloned().collect(),
Self::Sign(_) => Vec::new(),
Self::Jukebox(j) => j.record.iter().cloned().collect(),
Self::Jukebox(j) => j.items.iter().flatten().cloned().collect(),
Self::Beacon(_)
| Self::CreakingHeart(_)
| Self::Spawner(_)
@@ -454,7 +454,6 @@ impl BlockEntity {
Self::Furnace(f) => &f.items,
Self::Brewing(b) => &b.items,
Self::Sign(_)
| Self::Jukebox(_)
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
@@ -468,6 +467,7 @@ impl BlockEntity {
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => &[],
Self::Jukebox(j) => &j.items,
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::Shelf(b) => &b.items,
@@ -488,7 +488,6 @@ impl BlockEntity {
Self::Furnace(f) => &mut f.items,
Self::Brewing(b) => &mut b.items,
Self::Sign(_)
| Self::Jukebox(_)
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
@@ -502,6 +501,7 @@ impl BlockEntity {
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => &mut [],
Self::Jukebox(j) => &mut j.items,
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::Shelf(b) => &mut b.items,
@@ -645,6 +645,26 @@ mod tests {
assert_eq!(fifth.count, 1);
}
/// `JukeboxBlockEntity`'s single slot is a `Container` slot, so a hopper
/// (`HopperBlockEntity.getContainerAt`) can reach the disc in it.
#[test]
fn a_jukebox_offers_its_record_as_its_one_container_slot() {
let mut entity = BlockEntity::Jukebox(Jukebox::default());
assert_eq!(entity.slots().len(), 1);
assert!(entity.slots()[0].is_none(), "empty until a disc lands");
let disc = ItemStack::new("minecraft:cat", 1);
entity.slots_mut()[0] = Some(disc.clone());
let slots = entity.slots();
assert_eq!(slots[0].as_ref().map(|s| &*s.item), Some("minecraft:cat"));
match &entity {
BlockEntity::Jukebox(j) => {
assert_eq!(j.record().map(|s| &*s.item), Some("minecraft:cat"));
}
_ => panic!("a jukebox"),
}
assert_eq!(entity.contents().len(), 1);
}
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
@@ -357,7 +357,7 @@ impl Game<'_> {
for p in containers {
let entity = &self.world.block_entities[&p];
let single = match entity {
BlockEntity::Jukebox(j) => std::slice::from_ref(&j.record),
BlockEntity::Jukebox(j) => &j.items,
_ => entity.slots(),
};
for (i, slot) in single.iter().enumerate() {
@@ -744,7 +744,7 @@ impl Game<'_> {
let jukebox =
&*self.res.facts().get(self.world.block(p.0, p.1, p.2)).name == "minecraft:jukebox";
match self.world.block_entities.get_mut(&p) {
Some(BlockEntity::Jukebox(j)) => j.record = stack,
Some(BlockEntity::Jukebox(j)) => j.items[0] = stack,
Some(BlockEntity::Lectern(l)) => l.book = stack,
Some(BlockEntity::Campfire(c)) => {
if let Some(cell) = c.items.get_mut(slot) {
@@ -761,7 +761,7 @@ impl Game<'_> {
self.world.block_entities.insert(
p,
BlockEntity::Jukebox(horizon_client::containers::Jukebox {
record: stack,
items: [stack],
ticks: 0,
playing: false,
}),
+46 -6
View File
@@ -53,6 +53,47 @@ impl Game<'_> {
self.container_changed((pos[0], pos[1], pos[2]));
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// `JukeboxBlockEntity.setTheItem` from a container (a hopper or a dropper
/// feeding the slot): a disc that lands in a jukebox that is not playing
/// starts its song, as the player's own use does, with the block state,
/// the song's level event and the neighbour and comparator updates.
pub(super) fn jukebox_inserted(&mut self, pos: [i32; 3]) {
let Some(song) = self
.jukebox(pos)
.filter(|j| !j.playing)
.and_then(|j| j.record())
.and_then(|record| self.res.items().get(&record.item))
.and_then(|info| info.jukebox_song.clone())
else {
return;
};
if let Some(BlockEntity::Jukebox(j)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
j.playing = true;
j.ticks = 0;
}
self.set_has_record(pos, true);
self.start_song(pos, &song);
self.jukebox_changed(pos);
}
/// `JukeboxBlockEntity.setTheItem(EMPTY)` from a container (a hopper
/// below taking the disc): the song stops, the block loses its record,
/// and the neighbours and comparators hear it.
pub(super) fn jukebox_taken(&mut self, pos: [i32; 3]) {
if let Some(BlockEntity::Jukebox(j)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
if j.record().is_some() {
return;
}
j.playing = false;
j.ticks = 0;
}
self.stop_song(pos);
self.set_has_record(pos, false);
self.jukebox_changed(pos);
}
/// `JukeboxBlock.useItemOn` / `useWithoutItem` on the jukebox at `pos`.
pub(super) fn use_jukebox(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
if &*self
@@ -64,7 +105,7 @@ impl Game<'_> {
{
return false;
}
if self.jukebox(pos).is_some_and(|j| j.record.is_some()) {
if self.jukebox(pos).is_some_and(|j| j.record().is_some()) {
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
if self.hand != Hand::Main {
return false;
@@ -95,7 +136,7 @@ impl Game<'_> {
self.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Jukebox(Jukebox {
record: Some(record),
items: [Some(record)],
ticks: 0,
playing: true,
}),
@@ -200,7 +241,7 @@ impl Game<'_> {
Some(BlockEntity::Jukebox(j)) => {
j.playing = false;
j.ticks = 0;
j.record.take()
j.items[0].take()
}
_ => None,
};
@@ -224,7 +265,7 @@ impl Game<'_> {
/// `JukeboxSongPlayer.tick` of the jukebox at `key`, while it plays.
pub(super) fn tick_jukebox(&mut self, key: (i32, i32, i32)) {
let playing = match self.world.block_entities.get(&key) {
Some(BlockEntity::Jukebox(j)) if j.playing => j.record.as_ref().and_then(|r| {
Some(BlockEntity::Jukebox(j)) if j.playing => j.record().and_then(|r| {
let song = self.items().get(&r.item)?.jukebox_song.clone()?;
Some((song, j.ticks))
}),
@@ -314,8 +355,7 @@ pub(super) fn jukebox_signals(
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
Some(BlockEntity::Jukebox(j)) => {
let comparator = j
.record
.as_ref()
.record()
.and_then(|r| items.get(&r.item)?.jukebox_song.as_ref())
.and_then(|s| items.songs.get(s))
.map_or(0, |s| s.comparator);
@@ -46,6 +46,8 @@ enum Kind {
Brewing,
/// `ChiseledBookShelfBlockEntity`: books only, one to a slot.
Bookshelf,
/// `JukeboxBlockEntity`'s single slot: a playable disc only.
Jukebox,
/// A mod block's block entity (`ModContainer`).
Mod,
/// `ShulkerBoxBlockEntity`: a shulker box doesn't go through a face into one.
@@ -209,6 +211,7 @@ impl Containers<'_> {
(BlockEntity::Crafter(_), _) => Kind::Crafter,
(BlockEntity::Brewing(_), _) => Kind::Brewing,
(BlockEntity::ChiseledBookshelf(_), _) => Kind::Bookshelf,
(BlockEntity::Jukebox(_), _) => Kind::Jukebox,
(_, "minecraft:hopper") => Kind::Hopper,
(_, n) if is_shulker_box(n) => Kind::Shulker,
_ => Kind::Generic,
@@ -302,6 +305,15 @@ impl Containers<'_> {
_ => false,
};
}
if view.kind == Kind::Jukebox {
// JukeboxBlockEntity.canPlaceItem: a playable disc, into the one
// slot, which has to be empty first (its maximum stack is 1).
return self
.items
.get(&stack.item)
.is_some_and(|i| i.jukebox_song.is_some())
&& self.get(view, slot).is_none();
}
if view.kind == Kind::Bookshelf {
// ListBackedContainer.canPlaceItem: `acceptsItemType` (the books)
// and a free slot, as `getMaxStackSize` is 1.
@@ -572,6 +584,12 @@ impl Game<'_> {
let (rest, moved) = self.move_in_item(from_hopper, view, stack, slot);
if moved {
self.view_changed(view);
// JukeboxBlockEntity.setTheItem through a hopper's or a dropper's
// transfer: the disc it lands on starts playing.
if view.kind == Kind::Jukebox {
let (x, y, z) = view.parts[0].0;
self.jukebox_inserted([x, y, z]);
}
}
rest
}
@@ -868,6 +886,12 @@ impl Game<'_> {
match self.add_item(source_hopper, hopper, one, None) {
None => {
self.view_changed(source);
// JukeboxBlockEntity.removeItem -> setTheItem(EMPTY): a
// disc a hopper takes out stops the song.
if source.kind == Kind::Jukebox {
let (x, y, z) = source.parts[0].0;
self.jukebox_taken([x, y, z]);
}
return true;
}
Some(rest) => self.put_back(source, slot, rest),
+1 -1
View File
@@ -476,7 +476,7 @@ impl GameLevel<'_> {
// events and a neighbour update), the record pops, and
// onSongChanged updates the neighbours again.
if let Some(BlockEntity::Jukebox(j)) = &entity
&& let Some(record) = j.record.clone()
&& let Some(record) = j.record().cloned()
{
if j.playing {
self.jukebox_stopped(pos, old);
+2 -2
View File
@@ -667,7 +667,7 @@ pub fn encode_block_entity(
}
BlockEntity::Sign(sign) => entries.extend(sign.encode()),
BlockEntity::Jukebox(j) => {
if let Some(record) = &j.record {
if let Some(record) = j.record() {
entries.push(("RecordItem", item_to_nbt(record)));
}
entries.push(("ticks_since_song_started", Tag::Long(j.ticks)));
@@ -912,7 +912,7 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
return Some((
pos,
BlockEntity::Jukebox(crate::containers::Jukebox {
record,
items: [record],
ticks,
playing,
}),
+2 -2
View File
@@ -83,8 +83,8 @@ JukeboxBlockEntity.notifyItemChangedInJukebox port crates/horizon-client/src/gam
JukeboxBlockEntity.getComparatorOutput port crates/horizon-client/src/game/jukebox.rs::jukebox_signals comparator value of the song
JukeboxBlockEntity.loadAdditional port crates/horizon-client/src/save.rs::decode_block_entity RecordItem and ticks
JukeboxBlockEntity.saveAdditional port crates/horizon-client/src/save.rs::encode_block_entity RecordItem and ticks
JukeboxBlockEntity.setTheItem inline crates/horizon-client/src/game/jukebox.rs::Game::use_jukebox gap: player: hoppers and other containers cannot insert a disc
JukeboxBlockEntity.canPlaceItem port crates/horizon-client/src/game/jukebox.rs::Game::use_jukebox gap: player: hopper insertion of discs is missing
JukeboxBlockEntity.setTheItem port crates/horizon-client/src/game/jukebox.rs::Game::jukebox_inserted a disc a hopper or dropper feeds into the slot starts the song, with the block state, the level event and the neighbour and comparator updates
JukeboxBlockEntity.canPlaceItem port crates/horizon-client/src/game/machines.rs::Containers::can_place a playable disc into the empty single slot (`Jukebox.items`, which `BlockEntity.slots` now exposes), so a hopper reaches a jukebox
JukeboxBlockEntity.setSongItemWithoutPlaying skip:other @VisibleForTesting setter; no player behaviour
JukeboxBlockEntity.tryForcePlaySong skip:other @VisibleForTesting hook; no player behaviour
LecternBlockEntity.hasBook inline crates/horizon-client/src/game/books.rs::Game::use_lectern book-present check before a book is placed
1 BellBlockEntity.areRaidersNearby inline crates/horizon-client-render/src/block_entity_anim.rs::Bell::tick resonation start check (raider within 32 blocks) folded into Bell::tick
83 JukeboxBlockEntity.getComparatorOutput port crates/horizon-client/src/game/jukebox.rs::jukebox_signals comparator value of the song
84 JukeboxBlockEntity.loadAdditional port crates/horizon-client/src/save.rs::decode_block_entity RecordItem and ticks
85 JukeboxBlockEntity.saveAdditional port crates/horizon-client/src/save.rs::encode_block_entity RecordItem and ticks
86 JukeboxBlockEntity.setTheItem inline port crates/horizon-client/src/game/jukebox.rs::Game::use_jukebox crates/horizon-client/src/game/jukebox.rs::Game::jukebox_inserted gap: player: hoppers and other containers cannot insert a disc a disc a hopper or dropper feeds into the slot starts the song, with the block state, the level event and the neighbour and comparator updates
87 JukeboxBlockEntity.canPlaceItem port crates/horizon-client/src/game/jukebox.rs::Game::use_jukebox crates/horizon-client/src/game/machines.rs::Containers::can_place gap: player: hopper insertion of discs is missing a playable disc into the empty single slot (`Jukebox.items`, which `BlockEntity.slots` now exposes), so a hopper reaches a jukebox
88 JukeboxBlockEntity.setSongItemWithoutPlaying skip:other @VisibleForTesting setter; no player behaviour
89 JukeboxBlockEntity.tryForcePlaySong skip:other @VisibleForTesting hook; no player behaviour
90 LecternBlockEntity.hasBook inline crates/horizon-client/src/game/books.rs::Game::use_lectern book-present check before a book is placed