Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ public StudioListener(StudioService studio, StudioMenu menu) {
public void interact(PlayerInteractEvent event) {
if (event.getAction() != Action.RIGHT_CLICK_BLOCK || event.getClickedBlock() == null
|| event.useInteractedBlock() == Event.Result.DENY
// Check the type first: getState() copies block entities such as chest inventories.
|| event.getClickedBlock().getType() != Material.JUKEBOX
|| !(event.getClickedBlock().getState() instanceof Jukebox box)) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -597,8 +597,9 @@ private void tick() {
}
Block block = world.getBlockAt(key.x, key.y, key.z);
int elapsed = (int) (clock - playback.start);
if (!(block.getState() instanceof Jukebox box)
|| !playback.song.id().equals(discs.songId(box.getRecord()))
// The block type is cheap to read every tick. Reading the record copies the block state and
// item, so it is checked once a second; ejection and breaking stop playback immediately.
if (block.getType() != Material.JUKEBOX || elapsed % 20 == 0 && !holds(block, playback.song)
|| elapsed >= playback.song.lengthTicks() + settings.tailTicks()) {
it.remove();
continue;
Expand All @@ -619,6 +620,10 @@ private void tick() {
}
}

private boolean holds(Block block, Song song) {
return block.getState() instanceof Jukebox box && song.id().equals(discs.songId(box.getRecord()));
}

private void emit(Player player, List<Timeline.Trigger> due) {
for (Timeline.Trigger trigger : due) {
privateSounds.computeIfAbsent(player.getUniqueId(), key -> new HashSet<>()).add(trigger.note().sound());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
import org.bukkit.plugin.Plugin;
import org.bukkit.util.BoundingBox;

import java.lang.reflect.Method;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Logger;

/**
Expand All @@ -20,6 +23,7 @@
public final class StudioStation {
/** Squared reach within which a player can keep using the station's controls. */
private static final double REACH_SQUARED = 36;
private static final Map<String, Method> METHODS = new ConcurrentHashMap<>();
private final Material material;
private final String itemsAdderId;

Expand Down Expand Up @@ -135,13 +139,29 @@ public Block require(Player player, Location location) {
// CustomBlock.byAlreadyPlaced(block) / CustomFurniture.byAlreadySpawned(entity), then getNamespacedID().
private static String namespacedId(String type, String lookup, Class<?> parameter, Object target) {
try {
Object custom = Class.forName(type).getMethod(lookup, parameter).invoke(null, target);
return custom == null ? null : (String) custom.getClass().getMethod("getNamespacedID").invoke(custom);
Object custom = method(type + "#" + lookup + "#" + parameter.getName(),
() -> Class.forName(type).getMethod(lookup, parameter)).invoke(null, target);
return custom == null ? null : (String) method(custom.getClass().getName() + "#getNamespacedID",
() -> custom.getClass().getMethod("getNamespacedID")).invoke(custom);
} catch (ReflectiveOperationException | RuntimeException ex) {
return null;
}
}

@FunctionalInterface
private interface Lookup { Method find() throws ReflectiveOperationException; }

// matches(Block) runs on every right-click on a block and every studio menu refresh, so each
// reflective lookup is resolved once. Failures are not cached: ItemsAdder may enable later.
private static Method method(String key, Lookup lookup) throws ReflectiveOperationException {
Method method = METHODS.get(key);
if (method == null) {
method = lookup.find();
METHODS.put(key, method);
}
return method;
}

private static boolean pluginEnabled(String name) {
Plugin plugin = Bukkit.getPluginManager().getPlugin(name);
return plugin != null && plugin.isEnabled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ public StudioStore(Path root) throws IOException {
this.root = root;
Files.createDirectories(root.resolve("projects"));
Files.createDirectories(root.resolve("songs"));
// A crash between writing and moving leaves a temporary file; it never holds the only copy.
try (var files = Files.walk(root, 3)) {
for (Path file : files.filter(StudioStore::temporary).toList()) Files.deleteIfExists(file);
}
}

private static boolean temporary(Path file) {
String name = file.getFileName().toString();
return name.startsWith("studio-") && name.endsWith(".tmp");
}

public Optional<Project> project(UUID owner) throws IOException {
Expand Down Expand Up @@ -214,10 +223,11 @@ public void save(Project project) throws IOException {
Path previousFile = projectFile(owner, previous.song().id());
if (!Files.exists(previousFile)) write(previousFile, encodeProject(previous), true);
}
YamlConfiguration yaml = encodeProject(project);
// Both copies hold the same bytes, so the project is serialized once.
byte[] contents = encodeProject(project).saveToString().getBytes(StandardCharsets.UTF_8);
Path file = projectFile(owner, project.song().id());
write(file, yaml, true);
write(selectedFile, yaml, true);
write(file, contents, true);
write(selectedFile, contents, true);
archived.add(owner);
remember(project, file, selectedFile);
}
Expand Down Expand Up @@ -269,10 +279,13 @@ private YamlConfiguration read(Path file) throws IOException {
}

private void write(Path file, YamlConfiguration yaml, boolean replace) throws IOException {
write(file, yaml.saveToString().getBytes(StandardCharsets.UTF_8), replace);
}

private void write(Path file, byte[] contents, boolean replace) throws IOException {
if (!replace && Files.exists(file)) {
throw new IOException("Published editions cannot be overwritten");
}
byte[] contents = yaml.saveToString().getBytes(StandardCharsets.UTF_8);
if (contents.length > MAX_FILE_BYTES) {
throw new IOException("Studio file exceeds the size limit: " + file.getFileName());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
when(studio.discs()).thenReturn(discs);
when(studio.station()).thenReturn(StudioStation.none());
when(block.getState()).thenReturn(box);
when(block.getType()).thenReturn(Material.JUKEBOX);
when(player.getInventory()).thenReturn(inventory);
when(player.hasPermission("instruments.play")).thenReturn(true);
when(discs.custom(disc)).thenReturn(true);
Expand Down Expand Up @@ -164,6 +165,16 @@
assertEquals(Event.Result.DENY, off.useItemInHand());
}

@Test
void clicksOnOtherBlocksDoNotCopyTheirBlockState() throws Exception {
when(block.getType()).thenReturn(Material.CHEST);
PlayerInteractEvent click = event(EquipmentSlot.HAND);
listener.interact(click);
verify(block, never()).getState();
verify(studio, never()).play(any(), any());
assertNotEquals(Event.Result.DENY, click.useItemInHand());
}

@Test
void otherBlocksClicksAndSneakPlacementKeepVanillaBehaviour() throws Exception {
when(studio.station()).thenReturn(StudioStation.vanilla(Material.LOOM));
Expand Down Expand Up @@ -269,7 +280,7 @@
void worldLifecycleAndHoppersRespectCustomRecordingPlayback() {
listener.breakBlock(new org.bukkit.event.block.BlockBreakEvent(block, player));
verify(studio).stop(block);
listener.quit(new org.bukkit.event.player.PlayerQuitEvent(player, (net.kyori.adventure.text.Component) null));

Check warning on line 283 in src/test/java/net/tfminecraft/musicalinstruments/studio/StudioListenerTest.java

View workflow job for this annotation

GitHub Actions / build

PlayerQuitEvent(org.bukkit.entity.Player,net.kyori.adventure.text.Component) in org.bukkit.event.player.PlayerQuitEvent has been deprecated and marked for removal
verify(studio).quit(player);
var world = mock(org.bukkit.World.class);
when(world.getUID()).thenReturn(java.util.UUID.randomUUID());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,22 @@ private Block jukebox(Song song, ItemStack disc) throws IOException {
return block;
}

@Test
void jukeboxRecordIsCheckedOnceASecondAndARemovedJukeboxStopsAtOnce() throws Exception {
Song song = song(new Track(1, 60, 1, false, List.of(new Note(0, "lute", "first", 4, 1),
new Note(5, "lute", "second", 4, 1), new Note(25, "lute", "third", 4, 1))));
ItemStack disc = mock(ItemStack.class);
Block block = jukebox(song, disc);
studio.play(block, disc);
clearInvocations(block);
for (int elapsed = 0; elapsed <= 5; elapsed++) tick.run();
verify(block, times(1)).getState();
verify(player).playSound(any(Location.class), eq("second"), eq(SoundCategory.RECORDS), anyFloat(), anyFloat());
when(block.getType()).thenReturn(Material.AIR);
for (int elapsed = 6; elapsed <= 25; elapsed++) tick.run();
verify(player, never()).playSound(any(Location.class), eq("third"), any(SoundCategory.class), anyFloat(), anyFloat());
}

@Test
void jukeboxAppliesItsSnapshotAndPlaysEveryTrackOnlyToNearbyListeners() throws Exception {
Song song = song(track(1, "lute"), track(2, "flute"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ private Project project() {
return new Project(new Song(UUID.randomUUID(), UUID.randomUUID(), "Musician", "M煤sica", List.of(old)), pending, 120, false);
}

@Test
void startupRemovesTemporaryFilesLeftByAnInterruptedWrite() throws Exception {
Project project = project();
new StudioStore(directory).save(project);
Path folder = directory.resolve("projects").resolve(project.song().owner().toString());
Path leftover = Files.writeString(folder.resolve("studio-123.tmp"), "partial");
Path unrelated = Files.writeString(folder.resolve("notes.tmp"), "kept");
Path similar = Files.writeString(directory.resolve("songs").resolve("studio-readme.txt"), "kept");
StudioStore reopened = new StudioStore(directory);
assertFalse(Files.exists(leftover));
assertTrue(Files.exists(unrelated));
assertTrue(Files.exists(similar));
assertEquals(project, reopened.project(project.song().owner()).orElseThrow());
}

@Test
void projectsAndPendingTakesSurviveANewStoreInstance() throws Exception {
Project project = project();
Expand Down