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 @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID> unsaved = new HashSet<>();
// Clock tick of each player's last publication, kept only while the cooldown applies.
private final Map<UUID, Long> lastPublished = new HashMap<>();
private final Map<BlockKey, Playback> jukeboxes = new HashMap<>();
private final Map<UUID, Song> editions = new LinkedHashMap<>(16, 0.75f, true) {
@Override
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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());
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/studio.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.<material> vanilla block, e.g. v.loom (a jukebox cannot be used: it plays recorded discs)
# ia.<namespace:id> ItemsAdder custom block or furniture, e.g. ia.tfmc:music_station
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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());
}
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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"));
Expand Down