From 95df0f85bc43df8170c1e98c75d1e69f5331d948 Mon Sep 17 00:00:00 2001 From: Nowko Date: Sun, 11 Oct 2026 21:01:47 +0200 Subject: [PATCH] fix: bound published edition growth Published editions are kept forever because discs reference them, and republishing over a recorded disc added a new file every two clicks. Edition IDs are now derived from their content, so republishing unchanged music reuses the earlier edition. A configurable publish-cooldown-seconds (default 30, 0 disables) limits how often a player can publish. Co-Authored-By: Claude Opus 5.5 --- .../musicalinstruments/studio/Song.java | 4 --- .../studio/StudioService.java | 13 +++++++-- .../studio/StudioSettings.java | 6 +++-- .../studio/StudioStore.java | 18 +++++++++++++ src/main/resources/studio.yml | 3 +++ .../studio/StudioCommandWorkflowTest.java | 2 +- .../studio/StudioMenuTest.java | 10 +++---- .../studio/StudioModelTest.java | 6 ++--- .../studio/StudioServiceTest.java | 27 ++++++++++++++----- .../studio/StudioStoreTest.java | 25 ++++++++++++++--- 10 files changed, 86 insertions(+), 28 deletions(-) diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/Song.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/Song.java index 099b10d..adce688 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/Song.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/Song.java @@ -58,8 +58,4 @@ public Song replace(Track track) { public Song remove(int slot) { return new Song(id, owner, author, title, tracks.stream().filter(t -> t.slot() != slot).toList()); } - - public Song edition() { - return new Song(UUID.randomUUID(), owner, author, title, tracks); - } } diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java index aef90d9..ba8cf2c 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java @@ -52,6 +52,8 @@ public final class StudioService { // Owners whose cached project is newer than its files: a final take still saving or whose write failed. // Other cached projects match the disk, so disconnects and shutdown need not write them again. private final Set unsaved = new HashSet<>(); + // Clock tick of each player's last publication, kept only while the cooldown applies. + private final Map lastPublished = new HashMap<>(); private final Map jukeboxes = new HashMap<>(); private final Map editions = new LinkedHashMap<>(16, 0.75f, true) { @Override @@ -367,8 +369,15 @@ private Song publishEdition(Player player) throws IOException { if (!project.song().audible()) { throw new IllegalArgumentException("The mix needs at least one audible note"); } - Song edition = project.song().edition(); - store.publish(edition); // No item is consumed when publication fails. + // Each publication of changed music adds a file that discs may reference forever. + lastPublished.values().removeIf(at -> clock - at >= settings.publishCooldownTicks()); + Long last = lastPublished.get(player.getUniqueId()); + if (last != null) { + long seconds = (last + settings.publishCooldownTicks() - clock + 19) / 20; + throw new IllegalArgumentException("Wait " + seconds + " seconds before publishing again"); + } + Song edition = store.publishEdition(project.song()); // No item is consumed when publication fails. + lastPublished.put(player.getUniqueId(), clock); editions.put(edition.id(), edition); return edition; } diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioSettings.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioSettings.java index dc6e839..6d2d4c6 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioSettings.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioSettings.java @@ -3,7 +3,8 @@ import org.bukkit.configuration.file.YamlConfiguration; public record StudioSettings(int tracks, int durationTicks, int notesPerTrack, int notesPerSong, - int sessions, int radius, int countInTicks, int tailTicks, int checkpointTicks) { + int sessions, int radius, int countInTicks, int tailTicks, int checkpointTicks, + int publishCooldownTicks) { public static StudioSettings read(YamlConfiguration config) { return new StudioSettings(bound(config, "max-tracks", 4, 1, Song.MAX_TRACKS), bound(config, "max-duration-seconds", 180, 1, Song.MAX_TICKS / 20) * 20, @@ -13,7 +14,8 @@ public static StudioSettings read(YamlConfiguration config) { bound(config, "jukebox-radius", 32, 1, 128), bound(config, "count-in-seconds", 3, 1, 10) * 20, bound(config, "sample-tail-seconds", 3, 0, 10) * 20, - bound(config, "checkpoint-ticks", 200, 20, 1200)); + bound(config, "checkpoint-ticks", 200, 20, 1200), + bound(config, "publish-cooldown-seconds", 30, 0, 3600) * 20); } private static int bound(YamlConfiguration config, String key, int fallback, int min, int max) { diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java index 7f6c2e9..ae4a69d 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java @@ -27,6 +27,8 @@ public final class StudioStore { // Not a .yml file, so project listings skip it. static final String INDEX = "library.index"; private static final String SELECTED = "selected"; + // Stands in for the edition ID while hashing, so the ID depends only on the music and its labels. + private static final UUID CONTENT_ID = new UUID(0, 0); private final Path root; // Owners whose selected file already has its per-project copy. Both the main thread // and the checkpoint writer save, so the set is concurrent. @@ -234,6 +236,22 @@ public void publish(Song song) throws IOException { write(root.resolve("songs").resolve(song.id() + ".yml"), encodeSong(song), false); } + /** + * Publishes an immutable edition of the song. Editions are named after their content, so + * republishing an unchanged mix returns the earlier edition instead of adding another file. + */ + public Song publishEdition(Song song) throws IOException { + String content = encodeSong(new Song(CONTENT_ID, song.owner(), song.author(), song.title(), song.tracks())).saveToString(); + UUID id = UUID.nameUUIDFromBytes(content.getBytes(StandardCharsets.UTF_8)); + Song edition = new Song(id, song.owner(), song.author(), song.title(), song.tracks()); + if (Files.exists(root.resolve("songs").resolve(id + ".yml"))) { + if (!song(id).equals(edition)) throw new IOException("Published edition does not match its content: " + id); + return edition; + } + publish(edition); + return edition; + } + private YamlConfiguration read(Path file) throws IOException { if (Files.size(file) > MAX_FILE_BYTES) { throw new IOException("Studio file exceeds the size limit: " + file.getFileName()); diff --git a/src/main/resources/studio.yml b/src/main/resources/studio.yml index b227655..851155a 100644 --- a/src/main/resources/studio.yml +++ b/src/main/resources/studio.yml @@ -11,6 +11,9 @@ sample-tail-seconds: 3 # Background checkpoints run at this interval. A hard crash loses work since # the last completed checkpoint; stop/disconnect/shutdown save the complete take. checkpoint-ticks: 200 +# Minimum wait between a player's publications. Published editions are kept forever +# because discs reference them; republishing unchanged music reuses its edition. 0 disables. +publish-cooldown-seconds: 30 # Right-clicking this block or furniture opens the studio (permission instruments.record). # v. vanilla block, e.g. v.loom (a jukebox cannot be used: it plays recorded discs) # ia. ItemsAdder custom block or furniture, e.g. ia.tfmc:music_station diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioCommandWorkflowTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioCommandWorkflowTest.java index b09277f..f1095fb 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioCommandWorkflowTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioCommandWorkflowTest.java @@ -25,7 +25,7 @@ class StudioCommandWorkflowTest { @BeforeEach void setup() throws Exception { when(player.hasPermission("instruments.record")).thenReturn(true); - when(studio.settings()).thenReturn(new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 20)); + when(studio.settings()).thenReturn(new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 20, 0)); var first = new Track(1, 20, 1, false, List.of(new Note(0, "lute", "sample", 1, 1))); var second = new Track(2, 20, 1, true, first.notes()); project = new Project(new Song(UUID.randomUUID(), UUID.randomUUID(), "Author", "Old", List.of(first, second)), diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioMenuTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioMenuTest.java index aefbfdd..b0bf921 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioMenuTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioMenuTest.java @@ -112,7 +112,7 @@ void setup() throws Exception { when(studio.project(player)).thenReturn(project); when(studio.requireProject(player)).thenReturn(project); when(studio.listProjects(player)).thenReturn(List.of(ProjectSummary.of(project))); - when(studio.settings()).thenReturn(new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 2)); + when(studio.settings()).thenReturn(new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 2, 0)); when(studio.activity(player)).thenReturn(new StudioService.Activity(0, 0, 0, 0, false)); StudioIcons icons = mock(StudioIcons.class); when(icons.item(any(), any(), anyList())).thenReturn(mock(ItemStack.class)); @@ -301,7 +301,7 @@ void switchingTheActiveProjectRejectsAnAlreadyQueuedTrackClick() throws Exceptio @Test void tracksFiveToEightHaveTheirOwnPageAndPreserveTrackNumbers() throws Exception { - when(studio.settings()).thenReturn(new StudioSettings(8, 40, 20, 60, 8, 32, 2, 0, 2)); + when(studio.settings()).thenReturn(new StudioSettings(8, 40, 20, 60, 8, 32, 2, 0, 2, 0)); menu.openProject(player, station); click(53, ClickType.LEFT); executeClick(); @@ -432,7 +432,7 @@ void renderingShowsRecordingListeningMutedTracksAndAnEmptyLibrary() throws Excep Track muted = project.song().track(1).mix(.5f, true); project = new Project(project.song().replace(muted), null, 100, true); when(studio.requireProject(player)).thenReturn(project); - when(studio.settings()).thenReturn(new StudioSettings(2, 40, 20, 60, 8, 32, 2, 0, 2)); + when(studio.settings()).thenReturn(new StudioSettings(2, 40, 20, 60, 8, 32, 2, 0, 2, 0)); when(studio.activity(player)).thenReturn(new StudioService.Activity(1, 2, 1, 0, false)); menu.openProject(player, station); when(studio.activity(player)).thenReturn(new StudioService.Activity(0, 0, 0, 0, true)); @@ -474,7 +474,7 @@ void navigatingBackClosingAndUnassignedControlsHaveNoTrackEffects() throws Excep click(9, ClickType.LEFT); executeClick(); click(0, ClickType.LEFT); executeClick(); for (int raw : new int[]{9, 17, 46, 45, 53, 49}) { click(raw, ClickType.LEFT); executeClick(); } - when(studio.settings()).thenReturn(new StudioSettings(2, 40, 20, 60, 8, 32, 2, 0, 2)); + when(studio.settings()).thenReturn(new StudioSettings(2, 40, 20, 60, 8, 32, 2, 0, 2, 0)); click(28, ClickType.LEFT); executeClick(); verify(studio, never()).record(any(), anyInt()); } @@ -501,7 +501,7 @@ void volumeResetMissingSavedTracksDiscardAndTempoStepControlsAreValidated() thro @Test void trackPagingReturnsToThePreviousFourTracks() throws Exception { - when(studio.settings()).thenReturn(new StudioSettings(8, 40, 20, 60, 8, 32, 2, 0, 2)); + when(studio.settings()).thenReturn(new StudioSettings(8, 40, 20, 60, 8, 32, 2, 0, 2, 0)); menu.openProject(player, station); click(53, ClickType.LEFT); executeClick(); click(45, ClickType.LEFT); executeClick(); diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioModelTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioModelTest.java index 3ed96b0..021f596 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioModelTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioModelTest.java @@ -51,16 +51,14 @@ void audibilityIgnoresMutedSilentAndZeroVolumeTracks() { } @Test - void acceptingOrDiscardingATakePreservesPublishedEditions() { + void acceptingOrDiscardingATakeLeavesTheOriginalSongUnchanged() { Song original = song(new Track(1, 100, 1, false, List.of(note(0, "original")))); - Song edition = original.edition(); Track replacement = new Track(1, 50, 0.5f, false, List.of(note(20, "replacement"))); Project project = new Project(original, replacement, 100, true); assertEquals("original", project.discard().song().track(1).notes().getFirst().sound()); assertEquals("replacement", project.accept().song().track(1).notes().getFirst().sound()); assertEquals(50, project.accept().song().lengthTicks()); - assertEquals("original", edition.track(1).notes().getFirst().sound()); - assertNotEquals(original.id(), edition.id()); + assertEquals("original", original.track(1).notes().getFirst().sound()); assertNull(project.accept().pending()); } diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioServiceTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioServiceTest.java index 88599c6..4401d92 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioServiceTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioServiceTest.java @@ -245,7 +245,7 @@ void shutdownWaitIsBoundedAndInterruptionPreservesQueuedSaves(boolean interrupte var worker = spy(executor); if (interrupted) doThrow(new InterruptedException("Shutdown interrupted")).when(worker).awaitTermination(5, TimeUnit.SECONDS); else doReturn(false).when(worker).awaitTermination(5, TimeUnit.SECONDS); - studio = new StudioService(plugin, manager, store, new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 2), + studio = new StudioService(plugin, manager, store, new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 2, 0), StudioStation.none(), discs, worker); var callbacks = ArgumentCaptor.forClass(Runnable.class); verify(server.getScheduler(), times(2)).runTaskTimer(eq(plugin), callbacks.capture(), eq(1L), eq(1L)); @@ -323,7 +323,7 @@ void setup() throws Exception { when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); when(world.getPlayers()).thenReturn(List.of(player)); when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(1L))).thenReturn(task); - studio = new StudioService(plugin, manager, store, new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 2), + studio = new StudioService(plugin, manager, store, new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 2, 0), StudioStation.none(), discs); var callback = ArgumentCaptor.forClass(Runnable.class); verify(scheduler).runTaskTimer(eq(plugin), callback.capture(), eq(1L), eq(1L)); @@ -544,6 +544,22 @@ void failedPublicationPreservesThePhysicalBlankDisc() throws Exception { verify(inventory, never()).setItemInMainHand(any()); } + @Test + void publicationCooldownLimitsHowOftenEditionsCanBeWritten() throws Exception { + configure(new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 2, 40)); + studio.save(player, new Project(song(track(1, "note")), null, 100, false)); + ItemStack disc = mock(ItemStack.class); + when(discs.musicDisc(disc)).thenReturn(true); + when(disc.getAmount()).thenReturn(1); + when(discs.disc(any(Song.class), eq(disc))).thenReturn(disc); + studio.publishOnDisc(player, disc); + var wait = assertThrows(IllegalArgumentException.class, () -> studio.publishOnDisc(player, disc)); + assertEquals("Wait 2 seconds before publishing again", wait.getMessage()); + for (int elapsed = 0; elapsed < 40; elapsed++) tick.run(); + studio.publishOnDisc(player, disc); + verify(store, times(1)).publish(any()); // Unchanged music reuses the first edition. + } + private ItemStack inventoryItem(Material type, int amount, boolean blank) { ItemStack item = mock(ItemStack.class); Material material = mock(Material.class); @@ -716,8 +732,7 @@ void deletingActiveProjectDoesNotResurrectItAndKeepsOtherProjectsAndEditions() t Project other = studio.requireProject(player); Project active = new Project(song(track(1, "note")), null, 100, false); studio.save(player, active); - Song edition = active.song().edition(); - store.publish(edition); + Song edition = store.publishEdition(active.song()); studio.deleteProject(player, ProjectSummary.of(active)); assertNull(studio.project(player)); studio.quit(player); awaitSaves(); @@ -1079,7 +1094,7 @@ private void clock(long value) throws Exception { @Test void simultaneousNoteFloodsAndSongLimitsBoundCapture() throws Exception { - configure(new StudioSettings(4, 40, 50, 60, 8, 32, 2, 0, 2)); + configure(new StudioSettings(4, 40, 50, 60, 8, 32, 2, 0, 2, 0)); studio.create(player, "Song", false); studio.record(player, 1); tick.run(); tick.run(); for (int index = 0; index < 34; index++) studio.capture(player, "lute", "note", 1, 1); @@ -1095,7 +1110,7 @@ void simultaneousNoteFloodsAndSongLimitsBoundCapture() throws Exception { @Test void sessionCapacityDoesNotInterruptAnExistingPreviewAndPlaybackEndsCleanly() throws Exception { - configure(new StudioSettings(4, 40, 20, 60, 1, 32, 2, 0, 2)); + configure(new StudioSettings(4, 40, 20, 60, 1, 32, 2, 0, 2, 0)); studio.save(player, new Project(song(track(1, "note")), null, 100, false)); studio.preview(player, false); Player second = mock(Player.class); diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreTest.java index 488afe4..6c13709 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreTest.java @@ -48,14 +48,32 @@ void projectsAndPendingTakesSurviveANewStoreInstance() throws Exception { void publishedEditionRemainsImmutableAfterProjectChanges() throws Exception { StudioStore store = new StudioStore(directory); Project project = project(); - Song edition = project.song().edition(); - store.publish(edition); + Song edition = store.publishEdition(project.song()); + assertNotEquals(project.song().id(), edition.id()); store.save(project.accept()); assertEquals(edition, new StudioStore(directory).song(edition.id())); assertThrows(IOException.class, () -> store.publish(edition)); assertEquals(edition, store.song(edition.id())); } + @Test + void republishingUnchangedMusicReusesItsEditionWithoutAnotherFile() throws Exception { + StudioStore store = new StudioStore(directory); + Project project = project(); + Song edition = store.publishEdition(project.song()); + Song renamed = new Song(project.song().id(), project.song().owner(), "Musician", "Renamed", project.song().tracks()); + assertEquals(edition, store.publishEdition(project.song())); + assertNotEquals(edition.id(), store.publishEdition(renamed).id()); + try (var files = Files.list(directory.resolve("songs"))) { + assertEquals(2, files.count()); + } + Path file = directory.resolve("songs").resolve(edition.id() + ".yml"); + var yaml = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(file.toFile()); + yaml.set("title", "Tampered"); + yaml.save(file.toFile()); + assertThrows(IOException.class, () -> store.publishEdition(project.song())); + } + @Test void atomicReplacementLeavesNoTemporaryFiles() throws Exception { StudioStore store = new StudioStore(directory); @@ -89,8 +107,7 @@ void filenameIdentityMustMatchTheStoredOwnerOrEdition() throws Exception { Files.copy(directory.resolve("projects").resolve(project.song().owner() + ".yml"), directory.resolve("projects").resolve(anotherOwner + ".yml")); assertThrows(IOException.class, () -> store.project(anotherOwner)); - Song edition = project.song().edition(); - store.publish(edition); + Song edition = store.publishEdition(project.song()); UUID anotherId = UUID.randomUUID(); Files.copy(directory.resolve("songs").resolve(edition.id() + ".yml"), directory.resolve("songs").resolve(anotherId + ".yml"));