diff --git a/.github/compatibility/tlibs-2.3.xml b/.github/compatibility/tlibs-2.3.xml
new file mode 100644
index 0000000..47e0161
--- /dev/null
+++ b/.github/compatibility/tlibs-2.3.xml
@@ -0,0 +1,22 @@
+
+
+ 4.0.0
+ net.tfminecraft
+ studio-compatibility-fixture
+ main-SNAPSHOT
+
+ 21
+ UTF-8
+ 2.3.0
+
+
+
+ me.plugins
+ tlibs
+ ${tlibs.version}
+ provided
+
+
+
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8a6c6a2..af9ffc5 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -19,6 +19,19 @@ jobs:
java-version: '21'
cache: maven
+ - name: Install pinned shared plugins
+ uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74
+ with:
+ mode: pinned
+ token: ${{ github.token }}
+
+ - name: Install older API compatibility fixture
+ uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74
+ with:
+ mode: pinned
+ pom: .github/compatibility/tlibs-2.3.xml
+ lock: .build/legacy-plugin-dependencies.json
+
- name: Set build version
id: dev
shell: bash
diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml
index 159ccf6..1a96dff 100644
--- a/.github/workflows/maven-release.yml
+++ b/.github/workflows/maven-release.yml
@@ -36,6 +36,19 @@ jobs:
distribution: temurin
java-version: ${{ inputs.java-version }}
+ - name: Install pinned shared plugins
+ uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74
+ with:
+ mode: pinned
+ token: ${{ github.token }}
+
+ - name: Install older API compatibility fixture
+ uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74
+ with:
+ mode: pinned
+ pom: .github/compatibility/tlibs-2.3.xml
+ lock: .build/legacy-plugin-dependencies.json
+
- name: Validate release version
env:
TAG: ${{ github.ref_name }}
@@ -75,7 +88,8 @@ jobs:
run: |
python3 .github/scripts/plugin-artifact.py \
--jar "$ARTIFACT_PATH" --version "${TAG#v}" \
- --stage "$RUNNER_TEMP/plugin-release"
+ --stage "$RUNNER_TEMP/plugin-release" \
+ --dependencies .build/plugin-dependencies.json
- uses: actions/upload-artifact@v7
with:
diff --git a/pom.xml b/pom.xml
index 955e016..2385160 100644
--- a/pom.xml
+++ b/pom.xml
@@ -14,9 +14,22 @@
21
UTF-8
+ 2.6.0
+
+ me.plugins
+ tlibs
+ ${tlibs.version}
+ provided
+
+
+ *
+ *
+
+
+
org.junit.jupiter
junit-jupiter
diff --git a/src/main/java/net/tfminecraft/musicalinstruments/InstrumentPlugin.java b/src/main/java/net/tfminecraft/musicalinstruments/InstrumentPlugin.java
index e9106ed..bcfe22e 100644
--- a/src/main/java/net/tfminecraft/musicalinstruments/InstrumentPlugin.java
+++ b/src/main/java/net/tfminecraft/musicalinstruments/InstrumentPlugin.java
@@ -65,6 +65,7 @@ public void onEnable() {
// ItemsAdder can enable after this plugin despite the softdepend, so check the station on the first tick.
getServer().getScheduler().runTask(this, () -> studio.station().warnIfUnavailable(getLogger()));
studioMenu = new StudioMenu(this, studio);
+ studio.startOwnership(studioMenu);
StudioCommand musicCommand = new StudioCommand(studio, studioMenu);
getCommand("music").setExecutor(musicCommand);
getCommand("music").setTabCompleter(musicCommand);
diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioBridge.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioBridge.java
new file mode 100644
index 0000000..7ecf9c6
--- /dev/null
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioBridge.java
@@ -0,0 +1,53 @@
+package net.tfminecraft.musicalinstruments.studio;
+
+import java.util.Optional;
+import java.util.UUID;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.Listener;
+import org.bukkit.plugin.Plugin;
+import net.tfminecraft.tlibs.character.CharacterSession;
+import net.tfminecraft.tlibs.character.CharacterSessionEndEvent;
+import net.tfminecraft.tlibs.character.Characters;
+
+/** Loaded only after the optional TLibs character API has been probed. */
+public final class CharacterStudioBridge implements StudioIdentity {
+ public CharacterStudioBridge() {}
+
+ static Token token(CharacterSession session) {
+ return new Token(new Owner(session.ref().ownerId(), session.ref().characterId()), session.sessionId());
+ }
+
+ public Optional current(Player player) {
+ if (player == null) return Optional.empty();
+ return Characters.currentSession(player)
+ .filter(session -> session.ref().ownerId().equals(player.getUniqueId()))
+ .filter(Characters::isCurrent).map(CharacterStudioBridge::token);
+ }
+
+ public boolean current(Token token) {
+ if (token == null) return false;
+ Player player = Bukkit.getPlayer(token.owner().account());
+ return player != null && current(player).filter(token::equals).isPresent();
+ }
+
+ public boolean heirsReady() { return Characters.legacyHeirsReady(); }
+ public Optional heir(UUID account) {
+ if (account == null) return Optional.empty();
+ return Characters.legacyHeir(account).filter(ref -> ref.ownerId().equals(account))
+ .map(ref -> new Owner(ref.ownerId(), ref.characterId()));
+ }
+
+ public static void start(Plugin plugin, StudioService studio, StudioMenu menu) {
+ plugin.getServer().getPluginManager().registerEvents(new Listener() {
+ @EventHandler(priority = EventPriority.LOWEST)
+ public void end(CharacterSessionEndEvent event) {
+ // The event's session is already noncurrent; finish with its captured owner.
+ studio.end(event.getPlayer(), token(event.getSession()));
+ menu.end(event.getPlayer());
+ }
+ }, plugin);
+ }
+}
diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioEstate.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioEstate.java
new file mode 100644
index 0000000..7d305b5
--- /dev/null
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioEstate.java
@@ -0,0 +1,92 @@
+package net.tfminecraft.musicalinstruments.studio;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.plugin.ServicePriority;
+import net.tfminecraft.tlibs.character.Characters;
+import net.tfminecraft.tlibs.character.CharacterRef;
+import net.tfminecraft.tlibs.character.estate.EstateDecision;
+import net.tfminecraft.tlibs.character.estate.EstateParticipant;
+import net.tfminecraft.tlibs.character.estate.Estates;
+import net.tfminecraft.tlibs.character.estate.Holdings;
+import net.tfminecraft.tlibs.character.estate.Outcome;
+
+/** Published recordings stay immutable; only the editable project's authority moves. */
+final class CharacterStudioEstate implements EstateParticipant {
+ static final String ID = "studio";
+ private final StudioService studio;
+ private final StudioOwnership ownership;
+ private final Plugin plugin;
+ private final Map> reported = new HashMap<>();
+
+ CharacterStudioEstate(Plugin plugin, StudioService studio, StudioOwnership ownership) {
+ this.plugin = plugin; this.studio = studio; this.ownership = ownership;
+ }
+
+ static void start(Plugin plugin, StudioService studio, StudioOwnership ownership) {
+ CharacterStudioEstate participant = new CharacterStudioEstate(plugin, studio, ownership);
+ plugin.getServer().getServicesManager().register(EstateParticipant.class, participant, plugin, ServicePriority.Normal);
+ plugin.getServer().getScheduler().runTaskTimer(plugin, participant::reconcile, 1L, 72_000L);
+ }
+
+ public String id() { return ID; }
+ public Outcome settle(EstateDecision decision) { return settle(decision, false); }
+
+ private Outcome settle(EstateDecision decision, boolean inPass) {
+ if (!Estates.guardHolds(decision)) return Outcome.retry("The current estate decision does not hold");
+ UUID source = decision.testator().characterId();
+ if (!studio.quiescent(source)) return Outcome.retry("The original character's studio saves are still pending");
+ try {
+ ownership.requireEstateReady(owner(decision.testator()));
+ if (decision.property().character().isEmpty()) {
+ var previous = inPass
+ ? ownership.retainedReceiptsDuringPass(decision.deathId(), decision.toString(), owner(decision.testator()))
+ : ownership.retainedReceipts(decision.deathId(), decision.toString(), owner(decision.testator()));
+ return Outcome.done(Map.of("projects", previous.size()), previous);
+ }
+ CharacterRef recipient = decision.property().character().orElseThrow();
+ if (!studio.quiescent(recipient.characterId())) return Outcome.retry("The recipient's studio saves are still pending");
+ var moved = inPass
+ ? ownership.transferDuringPass(decision.deathId(), decision.toString(), owner(decision.testator()), owner(recipient),
+ () -> Estates.guardHolds(decision))
+ : ownership.transfer(decision.deathId(), decision.toString(), owner(decision.testator()), owner(recipient),
+ () -> Estates.guardHolds(decision));
+ studio.transferred(source);
+ studio.transferred(recipient.characterId());
+ return Outcome.done(Map.of("projects", moved.size()), moved);
+ } catch (StudioOwnership.Conflict ex) {
+ return Outcome.blocked(ex.getMessage());
+ } catch (IOException ex) {
+ return Outcome.retry("Could not save studio ownership: " + ex.getMessage());
+ }
+ }
+
+ public Holdings describe(UUID character) {
+ try {
+ var ids = ownership.holdings(character);
+ return ids.isEmpty() ? Holdings.NONE : new Holdings(Map.of("projects", ids.size()), ids);
+ } catch (IOException ex) {
+ return new Holdings(Map.of("heldLibraries", 1), java.util.List.of("Studio library needs recovery"));
+ }
+ }
+
+ void reconcile() {
+ ownership.beginPass();
+ try {
+ for (EstateDecision decision : Estates.decided()) {
+ try {
+ if (!Estates.guardHolds(decision)) continue;
+ Outcome outcome = settle(decision, true);
+ Map.Entry result = Map.entry(decision.revision(), outcome);
+ if (!result.equals(reported.get(decision.deathId()))
+ && Estates.report(decision.deathId(), ID, decision.revision(), outcome)) reported.put(decision.deathId(), result);
+ } catch (RuntimeException ex) { plugin.getLogger().warning("Studio estate failed: " + decision.deathId() + " " + ex); }
+ }
+ } finally { ownership.endPass(); }
+ }
+
+ private static StudioIdentity.Owner owner(CharacterRef ref) { return new StudioIdentity.Owner(ref.ownerId(), ref.characterId()); }
+}
diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/DurableFiles.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/DurableFiles.java
new file mode 100644
index 0000000..98b8a71
--- /dev/null
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/DurableFiles.java
@@ -0,0 +1,57 @@
+package net.tfminecraft.musicalinstruments.studio;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+
+/** Ownership publications acknowledge both the file and containing directory. */
+final class DurableFiles {
+ private DurableFiles() {}
+
+ static void write(Path target, byte[] bytes, boolean replace) throws IOException {
+ write(target, bytes, replace, true);
+ }
+
+ static void write(Path target, byte[] bytes, boolean replace, boolean strict) throws IOException {
+ Path folder = target.toAbsolutePath().getParent();
+ Files.createDirectories(folder);
+ Path temp = Files.createTempFile(folder, "studio-", ".tmp");
+ try {
+ try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
+ ByteBuffer buffer = ByteBuffer.wrap(bytes);
+ while (buffer.hasRemaining()) channel.write(buffer);
+ channel.force(true);
+ }
+ if (strict && !replace) {
+ // Atomic rename may overwrite even without REPLACE_EXISTING. A hard link cannot.
+ try { Files.createLink(target, temp); }
+ catch (UnsupportedOperationException ex) { throw new IOException("Durable no-clobber publication unavailable", ex); }
+ } else {
+ try {
+ if (replace) Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ else Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE);
+ } catch (java.nio.file.AtomicMoveNotSupportedException ex) {
+ if (strict) throw ex;
+ if (replace) Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
+ else Files.move(temp, target);
+ }
+ }
+ // Earlier failures may have left an existing but unsynced parent link.
+ for (Path path = folder; path != null; path = path.getParent()) force(path);
+ } finally {
+ Files.deleteIfExists(temp);
+ }
+ }
+
+ static void delete(Path target) throws IOException {
+ if (Files.deleteIfExists(target)) force(target.toAbsolutePath().getParent());
+ }
+
+ static void force(Path folder) throws IOException {
+ try (FileChannel channel = FileChannel.open(folder, StandardOpenOption.READ)) { channel.force(true); }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/RawYaml.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/RawYaml.java
new file mode 100644
index 0000000..5f90d43
--- /dev/null
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/RawYaml.java
@@ -0,0 +1,34 @@
+package net.tfminecraft.musicalinstruments.studio;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
+import org.yaml.snakeyaml.error.YAMLException;
+
+/** Bukkit's configuration setter removes nulls; raw project extension maps must keep them. */
+final class RawYaml {
+ private RawYaml() {}
+
+ static Map read(byte[] bytes) throws IOException {
+ LoaderOptions options = new LoaderOptions();
+ options.setCodePointLimit(StudioStore.MAX_FILE_BYTES);
+ options.setAllowDuplicateKeys(false);
+ try {
+ Object value = new Yaml(new SafeConstructor(options)).load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
+ if (!(value instanceof Map, ?> map)) throw new IOException("Project document is not a mapping");
+ Map result = new LinkedHashMap<>();
+ for (Map.Entry, ?> entry : map.entrySet()) {
+ if (!(entry.getKey() instanceof String key)) throw new IOException("Project property name is not text");
+ result.put(key, entry.getValue());
+ }
+ return result;
+ } catch (YAMLException ex) { throw new IOException("Unreadable project YAML", ex); }
+ }
+
+ static byte[] bytes(Map values) { return new Yaml().dump(values).getBytes(StandardCharsets.UTF_8); }
+}
diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioDialogs.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioDialogs.java
index f3da5b4..5bad946 100644
--- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioDialogs.java
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioDialogs.java
@@ -47,6 +47,7 @@ private interface Response { void apply(Player player, DialogResponseView view)
public void create(Player player, Location station) throws IOException {
studio.requireIdle(player);
+ StudioIdentity.Token token = studio.token(player);
studio.requireProjectSpace(player);
AtomicBoolean used = new AtomicBoolean();
Dialog dialog = Dialog.create(builder -> builder.empty()
@@ -57,29 +58,30 @@ public void create(Player player, Location station) throws IOException {
.canCloseWithEscape(true).build())
.type(DialogType.confirmation(
ActionButton.create(Component.text("Create", NamedTextColor.GREEN), null, 150,
- action(player, station, used, (target, view) -> {
+ action(player, token, station, used, (target, view) -> {
String title = view.getText("title");
if (title == null) throw new IllegalArgumentException("Enter a song title");
studio.create(target, title.strip(), false);
})),
ActionButton.create(Component.text("Back", NamedTextColor.GRAY), null, 150,
- action(player, station, used, (target, view) -> {}, library)))));
+ action(player, token, station, used, (target, view) -> {}, library)))));
player.closeInventory();
player.showDialog(dialog);
}
public void rename(Player player, Location station) throws IOException {
studio.requireIdle(player);
+ StudioIdentity.Token token = studio.token(player);
Project project = studio.requireProject(player);
AtomicBoolean used = new AtomicBoolean();
UUID id = project.song().id();
- var accept = action(player, station, used, (target, view) -> {
+ var accept = action(player, token, station, used, (target, view) -> {
String title = view.getText("title");
if (title == null) throw new IllegalArgumentException("Please enter a title");
Project current = studio.requireProject(target);
applySettings(target, id, title, current.bpm(), current.metronome());
});
- var cancel = action(player, station, used, (target, view) -> {});
+ var cancel = action(player, token, station, used, (target, view) -> {});
Dialog dialog = Dialog.create(builder -> builder.empty()
.base(DialogBase.builder(Component.text("Rename project", NamedTextColor.GOLD))
.inputs(List.of(DialogInput.text("title", Component.text("Song title"))
@@ -94,6 +96,7 @@ public void rename(Player player, Location station) throws IOException {
public void reset(Player player, Location station) throws IOException {
studio.requireIdle(player);
+ StudioIdentity.Token token = studio.token(player);
Project project = studio.requireProject(player);
AtomicBoolean used = new AtomicBoolean();
Dialog dialog = Dialog.create(builder -> builder.empty()
@@ -103,20 +106,21 @@ public void reset(Player player, Location station) throws IOException {
+ "'. Published discs keep their music.")))).canCloseWithEscape(true).build())
.type(DialogType.confirmation(
ActionButton.create(Component.text("Clear project", NamedTextColor.RED), null, 150,
- action(player, station, used, (target, view) -> {
+ action(player, token, station, used, (target, view) -> {
if (!studio.requireProject(target).equals(project)) {
throw new IllegalArgumentException("The project changed. Open the confirmation again");
}
studio.create(target, "Untitled", true);
})),
ActionButton.create(Component.text("Keep my song", NamedTextColor.GREEN), null, 150,
- action(player, station, used, (target, view) -> {})))));
+ action(player, token, station, used, (target, view) -> {})))));
player.closeInventory();
player.showDialog(dialog);
}
public void delete(Player player, Location station, UUID id) throws IOException {
studio.requireIdle(player);
+ StudioIdentity.Token token = studio.token(player);
Project project = studio.project(player, id);
AtomicBoolean used = new AtomicBoolean();
Dialog dialog = Dialog.create(builder -> builder.empty()
@@ -126,9 +130,9 @@ public void delete(Player player, Location station, UUID id) throws IOException
.canCloseWithEscape(true).build())
.type(DialogType.confirmation(
ActionButton.create(Component.text("Delete song", NamedTextColor.RED), null, 150,
- action(player, station, used, (target, view) -> studio.deleteProject(target, project), library)),
+ action(player, token, station, used, (target, view) -> studio.deleteProject(target, project), library)),
ActionButton.create(Component.text("Keep song", NamedTextColor.GREEN), null, 150,
- action(player, station, used, (target, view) -> {}, library)))));
+ action(player, token, station, used, (target, view) -> {}, library)))));
player.closeInventory();
player.showDialog(dialog);
}
@@ -143,17 +147,18 @@ void applySettings(Player player, UUID projectId, String title, int bpm, boolean
project.pending(), bpm, metronome));
}
- private DialogAction action(Player owner, Location station, AtomicBoolean used, Response response) {
- return action(owner, station, used, response, reopen);
+ private DialogAction action(Player owner, StudioIdentity.Token token, Location station, AtomicBoolean used, Response response) {
+ return action(owner, token, station, used, response, reopen);
}
- private DialogAction action(Player owner, Location station, AtomicBoolean used, Response response, Reopen destination) {
+ // The token only binds the click to this session; each response checks ownership again before changing anything.
+ private DialogAction action(Player owner, StudioIdentity.Token token, Location station, AtomicBoolean used, Response response, Reopen destination) {
UUID ownerId = owner.getUniqueId();
return DialogAction.customClick((view, audience) -> {
if (!(audience instanceof Player target) || !target.getUniqueId().equals(ownerId)
|| !used.compareAndSet(false, true) || !plugin.isEnabled()) return;
plugin.getServer().getScheduler().runTask(plugin, () -> {
- if (!target.isOnline()) return;
+ if (!target.isOnline() || !studio.current(token, target)) return;
try {
Block block = studio.station().require(target, station);
response.apply(target, view);
diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioEstate.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioEstate.java
new file mode 100644
index 0000000..c2d6be2
--- /dev/null
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioEstate.java
@@ -0,0 +1,19 @@
+package net.tfminecraft.musicalinstruments.studio;
+
+import org.bukkit.plugin.Plugin;
+
+/** The optional estate implementation is resolved only after all required API classes. */
+final class StudioEstate {
+ private StudioEstate() {}
+ static void startIfAvailable(Plugin plugin, StudioService studio, StudioOwnership ownership) {
+ try {
+ for (String name : new String[] {"Estates", "EstateParticipant", "EstateDecision", "Outcome"})
+ Class.forName("net.tfminecraft.tlibs.character.estate." + name);
+ Class.forName("net.tfminecraft.musicalinstruments.studio.CharacterStudioEstate")
+ .getDeclaredMethod("start", Plugin.class, StudioService.class, StudioOwnership.class)
+ .invoke(null, plugin, studio, ownership);
+ } catch (ReflectiveOperationException | LinkageError ex) {
+ plugin.getLogger().warning("Studio estate integration unavailable; projects stay with their owners: " + ex);
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioIdentity.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioIdentity.java
new file mode 100644
index 0000000..c2249bc
--- /dev/null
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioIdentity.java
@@ -0,0 +1,33 @@
+package net.tfminecraft.musicalinstruments.studio;
+
+import java.util.Optional;
+import java.util.UUID;
+import org.bukkit.entity.Player;
+
+/** Immutable routing tokens keep the optional character API outside the studio's models. */
+public interface StudioIdentity {
+ record Owner(UUID account, UUID character) {}
+ record Token(Owner owner, long session) {}
+
+ Optional current(Player player);
+ boolean current(Token token);
+ boolean heirsReady();
+ Optional heir(UUID account);
+
+ static StudioIdentity load() {
+ try {
+ Class> api = Class.forName("net.tfminecraft.tlibs.character.Characters");
+ api.getMethod("legacyHeir", UUID.class);
+ api.getMethod("legacyHeirsReady");
+ return (StudioIdentity) Class.forName("net.tfminecraft.musicalinstruments.studio.CharacterStudioBridge")
+ .getConstructor().newInstance();
+ } catch (ReflectiveOperationException | LinkageError ex) {
+ return new StudioIdentity() {
+ public Optional current(Player player) { return Optional.empty(); }
+ public boolean current(Token token) { return false; }
+ public boolean heirsReady() { return false; }
+ public Optional heir(UUID account) { return Optional.empty(); }
+ };
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioMenu.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioMenu.java
index d818d9b..3c707e6 100644
--- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioMenu.java
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioMenu.java
@@ -52,8 +52,9 @@ public StudioMenu(InstrumentPlugin plugin, StudioService studio) {
private void open(Player player, Block station, Screen screen, int page) throws IOException {
studio.station().require(player, station);
+ StudioIdentity.Token token = studio.token(player);
UUID projectId = screen == Screen.LIBRARY ? null : studio.requireProject(player).song().id();
- Holder holder = new Holder(player.getUniqueId(), station == null ? null : station.getLocation(), screen, page, projectId);
+ Holder holder = new Holder(token, station == null ? null : station.getLocation(), screen, page, projectId);
holder.inventory = plugin.getServer().createInventory(holder, screen == Screen.SETTINGS ? 27 : 54,
Component.text(screen == Screen.LIBRARY ? "\u266b Your song projects" : screen == Screen.SETTINGS
? "\u266b Song settings" : "\u266b Recording Studio", NamedTextColor.DARK_GRAY));
@@ -189,7 +190,7 @@ public void click(InventoryClickEvent event) {
if (event.getRawSlot() >= holder.inventory.getSize()
&& (event.getClick() == ClickType.LEFT || event.getClick() == ClickType.RIGHT)) return;
event.setCancelled(true);
- if (!(event.getWhoClicked() instanceof Player player) || !holder.owner.equals(player.getUniqueId())
+ if (!(event.getWhoClicked() instanceof Player player) || !studio.current(holder.token, player)
|| event.getRawSlot() < 0 || event.getRawSlot() >= holder.inventory.getSize()
|| !(event.getClick() == ClickType.LEFT || event.getClick() == ClickType.RIGHT
|| event.getClick() == ClickType.SHIFT_LEFT || event.getClick() == ClickType.SHIFT_RIGHT)) return;
@@ -200,7 +201,7 @@ public void click(InventoryClickEvent event) {
return;
}
plugin.getServer().getScheduler().runTask(plugin, () -> {
- if (!player.isOnline() || player.getOpenInventory().getTopInventory() != holder.inventory) return;
+ if (!player.isOnline() || !studio.current(holder.token, player) || player.getOpenInventory().getTopInventory() != holder.inventory) return;
try {
Block station = studio.station().require(player, holder.station);
if (raw == 8 && holder.screen != Screen.LIBRARY) { player.closeInventory(); return; }
@@ -269,7 +270,7 @@ private void deposit(Player player, Holder holder, ItemStack cursor) {
}
ItemStack original = cursor.clone();
plugin.getServer().getScheduler().runTask(plugin, () -> {
- if (!player.isOnline() || player.getOpenInventory().getTopInventory() != holder.inventory
+ if (!player.isOnline() || !studio.current(holder.token, player) || player.getOpenInventory().getTopInventory() != holder.inventory
|| !original.equals(player.getItemOnCursor())) return;
try {
studio.station().require(player, holder.station);
@@ -298,7 +299,7 @@ private void refresh() {
for (Player player : plugin.getServer().getOnlinePlayers()) {
if (!(open(player) instanceof Holder holder)) continue;
try {
- if (!holder.owner.equals(player.getUniqueId())) { player.closeInventory(); continue; }
+ if (!studio.current(holder.token, player)) { player.closeInventory(); continue; }
studio.station().require(player, holder.station);
if (holder.screen == Screen.LIBRARY) continue;
Project project = studio.requireProject(player);
@@ -315,11 +316,15 @@ public void drag(InventoryDragEvent event) {
if (event.getRawSlots().stream().noneMatch(slot -> slot < holder.inventory.getSize())) return;
event.setCancelled(true);
if (holder.screen == Screen.EDITOR && event.getRawSlots().equals(java.util.Set.of(50))
- && event.getWhoClicked() instanceof Player player && holder.owner.equals(player.getUniqueId())) {
+ && event.getWhoClicked() instanceof Player player && studio.current(holder.token, player)) {
deposit(player, holder, event.getOldCursor());
}
}
+ public void end(Player player) {
+ if (open(player) instanceof Holder) player.closeInventory();
+ }
+
public void close() {
refresher.cancel();
for (Player player : plugin.getServer().getOnlinePlayers()) {
@@ -333,7 +338,7 @@ private static InventoryHolder open(Player player) {
}
private static final class Holder implements InventoryHolder {
- private final UUID owner;
+ private final StudioIdentity.Token token;
private final Location station;
private final Screen screen;
private final UUID projectId;
@@ -346,8 +351,8 @@ private static final class Holder implements InventoryHolder {
private ItemStack overwriteDisc;
private Project overwriteProject;
private long overwriteDeadline;
- private Holder(UUID owner, Location station, Screen screen, int page, UUID projectId) {
- this.owner = owner; this.station = station; this.screen = screen; this.page = page; this.projectId = projectId;
+ private Holder(StudioIdentity.Token token, Location station, Screen screen, int page, UUID projectId) {
+ this.token = token; this.station = station; this.screen = screen; this.page = page; this.projectId = projectId;
}
@Override public Inventory getInventory() { return inventory; }
}
diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java
new file mode 100644
index 0000000..7a7de28
--- /dev/null
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java
@@ -0,0 +1,537 @@
+package net.tfminecraft.musicalinstruments.studio;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.Properties;
+import java.util.UUID;
+import java.util.function.BooleanSupplier;
+import org.bukkit.configuration.InvalidConfigurationException;
+import org.bukkit.configuration.file.YamlConfiguration;
+
+/** Raw project publications with immutable source bytes and checked replay receipts. */
+final class StudioOwnership {
+ static final class Conflict extends IOException {
+ Conflict(String message) { super(message); }
+ }
+
+ private final StudioStore store;
+ private final Path root;
+ private record Locks(String digest, java.util.Set characters, java.util.Set projects) {}
+ private final java.util.Map lockCache = new java.util.HashMap<>();
+ // A reconciliation snapshot only identifies operations with no effects. Own journal writes are reread,
+ // and an operation checks all current journals before its first backup, write or publication.
+ private boolean pass;
+ private java.util.Map passLocks;
+ private final java.util.Set passWrites = new java.util.HashSet<>();
+ private record Readiness(String digest, boolean complete, String toCharacter, boolean otherHeld, String heldSelected, String selected) {}
+ private final java.util.Map readiness = new java.util.HashMap<>();
+ StudioOwnership(StudioStore store) { this.store = store; this.root = store.root(); }
+
+ void migrate(StudioIdentity identity) throws IOException {
+ if (!identity.heirsReady()) return;
+ Path legacy = root.resolve("projects");
+ if (!Files.isDirectory(legacy)) return;
+ IOException failure = null;
+ for (Path folder : files(legacy)) {
+ try { migrate(identity, folder); }
+ catch (IOException ex) { if (failure == null) failure = ex; else failure.addSuppressed(ex); }
+ }
+ if (failure != null) throw failure;
+ }
+
+ private void migrate(StudioIdentity identity, Path folder) throws IOException {
+ if (!Files.isDirectory(folder)) { holdUnrecognized(folder); return; }
+ UUID account;
+ try { account = UUID.fromString(folder.getFileName().toString()); }
+ catch (IllegalArgumentException ex) { holdUnrecognized(folder); return; }
+ Path journalFile = root.resolve("ownership/migration").resolve(account + ".xml");
+ Properties journal = read(journalFile);
+ StudioIdentity.Owner to;
+ if (journal.isEmpty()) {
+ to = identity.heir(account).orElse(null);
+ if (to == null) return;
+ journal.setProperty("format", "1");
+ journal.setProperty("kind", "migration");
+ journal.setProperty("from-account", account.toString());
+ journal.setProperty("to-account", to.account().toString());
+ journal.setProperty("to-character", to.character().toString());
+ for (Path source : projects(folder)) {
+ String id = source.getFileName().toString().replace(".yml", "");
+ byte[] bytes = Files.readAllBytes(source);
+ try {
+ UUID.fromString(id);
+ var yaml = yaml(bytes);
+ if (!Integer.valueOf(StudioStore.FORMAT).equals(yaml.get("format")) || !account.toString().equals(yaml.get("owner"))
+ || !id.equals(yaml.get("id"))) throw new Conflict("Legacy project identity mismatch");
+ plan(journal, id, source, store.projectFile(to.character(), UUID.fromString(id)), bytes, to);
+ } catch (IOException | IllegalArgumentException ex) {
+ journal.setProperty("held." + id, ex.getMessage());
+ journal.setProperty("held-source." + id, Base64.getEncoder().encodeToString(bytes));
+ }
+ }
+ Path selected = folder.resolve(StudioStore.SELECTED);
+ if (Files.exists(selected)) journal.setProperty("selected", Base64.getEncoder().encodeToString(Files.readAllBytes(selected)));
+ journal(journalFile, journal); // Entire inventory/backup precedes the first publication.
+ } else {
+ to = owner(journal, "to");
+ }
+ for (String id : ids(journal)) apply(journalFile, journal, id, () -> true, false);
+ if (!"true".equals(journal.getProperty("complete"))) {
+ String selected = journal.getProperty("selected");
+ Path target = store.directory(to.character()).resolve(StudioStore.SELECTED);
+ if (selected != null && !Files.exists(target)) {
+ byte[] pointer = Base64.getDecoder().decode(selected);
+ try {
+ UUID id = UUID.fromString(new String(pointer, StandardCharsets.UTF_8).strip());
+ if (Files.exists(store.projectFile(to.character(), id))) DurableFiles.write(target, pointer, false);
+ } catch (IllegalArgumentException ex) { journal.setProperty("held.selected", "Invalid project selection"); }
+ }
+ journal.setProperty("complete", "true");
+ journal(journalFile, journal);
+ }
+ }
+
+ private void holdUnrecognized(Path source) throws IOException {
+ Path file = root.resolve("ownership/migration").resolve("unrecognized-" + hash(root.relativize(source).toString().getBytes(StandardCharsets.UTF_8)) + ".xml");
+ if (Files.exists(file)) { read(file); return; }
+ Properties journal = new Properties();
+ journal.setProperty("format", "1"); journal.setProperty("kind", "migration");
+ List records = Files.isDirectory(source) ? projects(source) : List.of(source);
+ for (Path record : records) {
+ String key = root.relativize(record).toString();
+ byte[] bytes = Files.readAllBytes(record);
+ journal.setProperty("held." + key, "Unrecognized legacy account path");
+ journal.setProperty("held-source." + key, Base64.getEncoder().encodeToString(bytes));
+ }
+ journal(file, journal);
+ }
+
+ List transfer(UUID death, String exactDecision, StudioIdentity.Owner from, StudioIdentity.Owner to,
+ BooleanSupplier guard) throws IOException {
+ return transfer(death, exactDecision, from, to, guard, false);
+ }
+
+ List transferDuringPass(UUID death, String exactDecision, StudioIdentity.Owner from, StudioIdentity.Owner to,
+ BooleanSupplier guard) throws IOException {
+ return transfer(death, exactDecision, from, to, guard, true);
+ }
+
+ private List transfer(UUID death, String exactDecision, StudioIdentity.Owner from, StudioIdentity.Owner to,
+ BooleanSupplier guard, boolean usePass) throws IOException {
+ if (from.equals(to)) throw new Conflict("Estate recipient is the testator");
+ long revision = validateDecision(exactDecision, death, from, to);
+ Path file = root.resolve("ownership/estates").resolve(death + ".xml");
+ Properties journal = read(file);
+ boolean changed = journal.isEmpty();
+ if (journal.isEmpty()) {
+ journal.setProperty("format", "1");
+ journal.setProperty("kind", "estate");
+ journal.setProperty("death", death.toString());
+ journal.setProperty("from-account", from.account().toString());
+ journal.setProperty("from-character", from.character().toString());
+ }
+ if (!from.equals(owner(journal, "from")))
+ throw new Conflict("Estate journal identity differs from this death");
+ Reservation reservation = requireUnreserved(file, journal, store.directory(from.character()), usePass);
+ for (String id : ids(journal)) {
+ if ("moved".equals(journal.getProperty(id + ".state"))) continue;
+ if (!exactDecision.equals(journal.getProperty(id + ".decision"))) {
+ // Only a plan with no published output can be superseded by a new decision.
+ if (!"planned".equals(journal.getProperty(id + ".state"))
+ || Files.exists(path(journal.getProperty(id + ".target"))))
+ throw new Conflict("Stale partial transfer needs staff recovery: " + id);
+ removeEntry(journal, id); changed = true;
+ }
+ }
+ if (!guard.getAsBoolean()) throw new IOException("Estate decision no longer holds");
+ for (Path source : projects(store.directory(from.character()))) {
+ String id = source.getFileName().toString().replace(".yml", "");
+ if ("moved".equals(journal.getProperty(id + ".state"))) throw new Conflict("Transferred project reappeared at its previous owner: " + id);
+ if (journal.containsKey(id + ".state")) continue;
+ UUID projectId;
+ try { projectId = UUID.fromString(id); }
+ catch (IllegalArgumentException ex) { throw new Conflict("Unrecognized project file: " + source); }
+ byte[] before = Files.readAllBytes(source);
+ var yaml = yaml(before);
+ if (!id.equals(yaml.get("id")) || !from.account().toString().equals(yaml.get("owner-account"))
+ || !from.character().toString().equals(yaml.get("owner-character")))
+ throw new Conflict("Unresolved project authority: " + id);
+ reservation.beforeEffect();
+ plan(journal, id, source, store.projectFile(to.character(), projectId), before, to);
+ journal.setProperty(id + ".from-account", from.account().toString());
+ journal.setProperty(id + ".from-character", from.character().toString());
+ journal.setProperty(id + ".decision", exactDecision);
+ journal.setProperty(id + ".revision", Long.toString(revision));
+ changed = true;
+ }
+ if (changed) { reservation.beforeEffect(); journal(file, journal); }
+ for (String id : ids(journal)) {
+ if (!"moved".equals(journal.getProperty(id + ".state"))) { reservation.beforeEffect(); apply(file, journal, id, guard, true); }
+ }
+ return ids(journal).stream().filter(id -> "moved".equals(journal.getProperty(id + ".state"))).toList();
+ }
+
+ List retainedReceipts(UUID death, String exactDecision, StudioIdentity.Owner from) throws IOException {
+ return retainedReceipts(death, exactDecision, from, false);
+ }
+
+ List retainedReceiptsDuringPass(UUID death, String exactDecision, StudioIdentity.Owner from) throws IOException {
+ return retainedReceipts(death, exactDecision, from, true);
+ }
+
+ private List retainedReceipts(UUID death, String exactDecision, StudioIdentity.Owner from, boolean usePass) throws IOException {
+ Path file = root.resolve("ownership/estates").resolve(death + ".xml");
+ Properties journal = read(file);
+ boolean changed = false;
+ if (!journal.isEmpty() && !from.equals(owner(journal, "from")))
+ throw new Conflict("Estate journal identity differs from this death");
+ Reservation reservation = requireUnreserved(file, journal, store.directory(from.character()), usePass);
+ for (Path source : projects(store.directory(from.character()))) {
+ String id = source.getFileName().toString().replace(".yml", "");
+ if ("moved".equals(journal.getProperty(id + ".state")))
+ throw new Conflict("Transferred project reappeared at its previous owner: " + id);
+ }
+ for (String id : ids(journal)) {
+ if ("moved".equals(journal.getProperty(id + ".state"))) continue;
+ if (!exactDecision.equals(journal.getProperty(id + ".decision"))) {
+ if (!"planned".equals(journal.getProperty(id + ".state")) || Files.exists(path(journal.getProperty(id + ".target"))))
+ throw new Conflict("Stale partial transfer needs staff recovery: " + id);
+ removeEntry(journal, id); changed = true;
+ }
+ }
+ if (changed) { reservation.beforeEffect(); journal(file, journal); }
+ return ids(journal).stream().filter(id -> "moved".equals(journal.getProperty(id + ".state"))).toList();
+ }
+
+ private static long validateDecision(String decision, UUID death, StudioIdentity.Owner from, StudioIdentity.Owner to) throws IOException {
+ var suffix = java.util.regex.Pattern.compile(", revision=(0|[1-9][0-9]*)]$").matcher(decision);
+ if (!decision.startsWith("EstateDecision[deathId=" + death + ", testator=" + reference(from) + ", diedAt=")
+ || !decision.contains(", property=Character[ref=" + reference(to) + "], guildLocks=") || !suffix.find())
+ throw new Conflict("Project plan differs from its captured full decision");
+ try { return Long.parseLong(suffix.group(1)); }
+ catch (NumberFormatException ex) { throw new Conflict("Unsupported project estate revision"); }
+ }
+ private static String reference(StudioIdentity.Owner owner) {
+ return "CharacterRef[ownerId=" + owner.account() + ", characterId=" + owner.character() + "]";
+ }
+
+ private void plan(Properties journal, String id, Path source, Path target, byte[] bytes, StudioIdentity.Owner to) throws IOException {
+ Path backup = root.resolve("ownership/backups").resolve(hash(bytes) + ".yml");
+ if (!Files.exists(backup)) DurableFiles.write(backup, bytes, false);
+ else if (!hash(Files.readAllBytes(backup)).equals(hash(bytes))) throw new Conflict("Ownership backup differs from its hash");
+ var yaml = yaml(bytes);
+ yaml.put("owner-account", to.account().toString());
+ yaml.put("owner-character", to.character().toString());
+ byte[] after = RawYaml.bytes(yaml);
+ journal.setProperty(id + ".source", root.relativize(source).toString());
+ journal.setProperty(id + ".target", root.relativize(target).toString());
+ journal.setProperty(id + ".before", Base64.getEncoder().encodeToString(bytes));
+ journal.setProperty(id + ".after", Base64.getEncoder().encodeToString(after));
+ journal.setProperty(id + ".before-hash", hash(bytes));
+ journal.setProperty(id + ".after-hash", hash(after));
+ journal.setProperty(id + ".to-account", to.account().toString());
+ journal.setProperty(id + ".to-character", to.character().toString());
+ journal.setProperty(id + ".state", "planned");
+ }
+
+ private void apply(Path file, Properties journal, String id, BooleanSupplier guard, boolean removeSource) throws IOException {
+ if ("moved".equals(journal.getProperty(id + ".state"))) return;
+ Path source = path(journal.getProperty(id + ".source"));
+ Path target = path(journal.getProperty(id + ".target"));
+ if (source.equals(target)) throw new Conflict("Source and destination refer to the same project");
+ if (Files.exists(source) && !hash(Files.readAllBytes(source)).equals(journal.getProperty(id + ".before-hash")))
+ throw new Conflict("Project changed after ownership planning: " + source);
+ if (!Files.exists(source) && "planned".equals(journal.getProperty(id + ".state")))
+ throw new Conflict("Planned source project disappeared: " + source);
+ if (!Files.exists(source) && !Files.exists(target)) throw new Conflict("Both published project copies disappeared");
+ if ("published".equals(journal.getProperty(id + ".state")) && !Files.exists(target))
+ throw new Conflict("Published project target disappeared; staff recovery required: " + target);
+ if (Files.exists(target)) {
+ if (!hash(Files.readAllBytes(target)).equals(journal.getProperty(id + ".after-hash")))
+ throw new Conflict("Recipient project conflicts: " + target);
+ } else {
+ if (!guard.getAsBoolean()) throw new IOException("Estate decision changed before publication");
+ DurableFiles.write(target, Base64.getDecoder().decode(journal.getProperty(id + ".after")), false);
+ }
+ journal.setProperty(id + ".state", "published");
+ journal(file, journal);
+ if (removeSource) {
+ if (!guard.getAsBoolean()) throw new IOException("Estate decision changed before source removal");
+ DurableFiles.delete(source);
+ }
+ journal.setProperty(id + ".state", "moved");
+ journal(file, journal);
+ }
+
+ void requireWritable(UUID character) throws IOException {
+ for (String kind : List.of("migration", "estates")) for (Path file : files(root.resolve("ownership").resolve(kind)))
+ if (locks(file).characters().contains(character)) throw new Conflict("Studio ownership publication needs recovery: " + file);
+ }
+
+ // Copies at either end of an unfinished publication still belong to that operation.
+ private Reservation requireUnreserved(Path current, Properties journal, Path folder, boolean usePass) throws IOException {
+ java.util.Set wanted = new java.util.HashSet<>();
+ for (String id : ids(journal)) if (!"moved".equals(journal.getProperty(id + ".state"))) wanted.add(id);
+ for (Path source : projects(folder)) wanted.add(source.getFileName().toString().replace(".yml", ""));
+ boolean snapshot = usePass && pass;
+ Reservation reservation = new Reservation(current, wanted, !snapshot);
+ reservation.check(snapshot);
+ return reservation;
+ }
+
+ // A pass may decide that an operation has nothing to do from its snapshot. Before the operation's
+ // first backup, journal write or publication, every journal is read again without the snapshot.
+ private final class Reservation {
+ private final Path current; private final java.util.Set wanted; private boolean exact;
+ Reservation(Path current, java.util.Set wanted, boolean exact) { this.current = current; this.wanted = wanted; this.exact = exact; }
+
+ void beforeEffect() throws IOException {
+ if (exact) return;
+ exact = true;
+ check(false);
+ }
+
+ private void check(boolean snapshot) throws IOException {
+ for (Path file : snapshot ? reservations() : listed()) {
+ Locks pending = snapshot ? reserved(file) : locks(file);
+ if (file.equals(current)) continue;
+ for (String id : pending.projects())
+ if (wanted.contains(id)) throw new Conflict("Project is still pending in another ownership journal: " + id);
+ }
+ }
+ }
+
+ private List listed() throws IOException {
+ List listed = new ArrayList<>();
+ for (String kind : List.of("migration", "estates")) listed.addAll(files(root.resolve("ownership").resolve(kind)));
+ return listed;
+ }
+
+ void beginPass() { pass = true; passLocks = null; passWrites.clear(); }
+
+ void endPass() { pass = false; passLocks = null; passWrites.clear(); }
+
+ private java.util.Collection reservations() throws IOException {
+ List listed = passLocks != null ? new ArrayList<>(passLocks.keySet()) : listed();
+ if (passLocks == null) {
+ var taken = new java.util.LinkedHashMap();
+ for (Path file : listed) {
+ try { taken.put(file, locks(file)); }
+ catch (IOException ex) { taken.put(file, ex); }
+ }
+ passLocks = taken;
+ }
+ var all = new java.util.LinkedHashSet<>(listed); all.addAll(passWrites);
+ return all;
+ }
+
+ private Locks reserved(Path file) throws IOException {
+ if (passWrites.contains(file)) return locks(file);
+ Object known = passLocks.get(file);
+ if (known instanceof Locks found) return found;
+ IOException failure = (IOException) known;
+ throw failure instanceof Conflict ? new Conflict(failure.getMessage()) : new IOException(failure.getMessage(), failure);
+ }
+
+ private void journal(Path file, Properties journal) throws IOException {
+ if (pass) passWrites.add(file);
+ write(file, journal);
+ }
+
+ private Locks locks(Path file) throws IOException {
+ byte[] bytes = Files.readAllBytes(file);
+ String digest = hash(bytes);
+ Locks cached = lockCache.get(file);
+ if (cached == null || !digest.equals(cached.digest())) {
+ Properties journal = read(file, bytes);
+ java.util.Set pending = new java.util.HashSet<>();
+ java.util.Set projects = new java.util.HashSet<>();
+ for (String id : ids(journal)) if (!"moved".equals(journal.getProperty(id + ".state"))) {
+ projects.add(id);
+ pending.add(owner(journal, id + ".to").character());
+ if ("estate".equals(journal.getProperty("kind"))) pending.add(owner(journal, "from").character());
+ }
+ cached = new Locks(digest, java.util.Set.copyOf(pending), java.util.Set.copyOf(projects));
+ lockCache.put(file, cached);
+ }
+ return cached;
+ }
+
+ List holdings(UUID character) throws IOException {
+ return projects(store.directory(character)).stream().map(file -> file.getFileName().toString().replace(".yml", "")).toList();
+ }
+
+ void requireEstateReady(StudioIdentity.Owner from) throws IOException {
+ Path legacy = root.resolve("projects").resolve(from.account().toString());
+ if (projects(legacy).isEmpty()) return;
+ Readiness journal = readiness(root.resolve("ownership/migration").resolve(from.account() + ".xml"));
+ if (journal == null || !journal.complete())
+ throw new IOException("The account's reviewed legacy project migration is not complete");
+ if (from.character().toString().equals(journal.toCharacter())
+ && (journal.otherHeld() || journal.heldSelected() != null && !invalidSelectionOnly(journal, legacy)))
+ throw new Conflict("Legacy projects for this character remain held for staff");
+ }
+
+ // Memoize validated journal fields by exact bytes; the live legacy selection is checked separately.
+ private Readiness readiness(Path file) throws IOException {
+ if (!Files.exists(file)) return null;
+ byte[] bytes = Files.readAllBytes(file);
+ String digest = hash(bytes);
+ Readiness cached = readiness.get(file);
+ if (cached != null && cached.digest().equals(digest)) return cached;
+ Properties journal = read(file, bytes);
+ boolean otherHeld = journal.stringPropertyNames().stream().anyMatch(key -> key.startsWith("held.") && !key.equals("held.selected"));
+ cached = new Readiness(digest, "true".equals(journal.getProperty("complete")), journal.getProperty("to-character"), otherHeld,
+ journal.getProperty("held.selected"), journal.getProperty("selected"));
+ readiness.put(file, cached);
+ return cached;
+ }
+
+ private boolean invalidSelectionOnly(Readiness journal, Path legacy) throws IOException {
+ if (!"Invalid project selection".equals(journal.heldSelected()) || journal.selected() == null) return false;
+ byte[] pointer;
+ try { pointer = Base64.getDecoder().decode(journal.selected()); }
+ catch (IllegalArgumentException malformed) { return false; }
+ Path source = legacy.resolve(StudioStore.SELECTED);
+ if (!Files.isRegularFile(source) || !java.util.Arrays.equals(pointer, Files.readAllBytes(source))) return false;
+ try { UUID.fromString(new String(pointer, StandardCharsets.UTF_8).strip()); return false; }
+ catch (IllegalArgumentException invalid) { return true; }
+ }
+
+ private Path path(String relative) { return root.resolve(relative).normalize(); }
+
+ private static StudioIdentity.Owner owner(Properties values, String prefix) throws Conflict {
+ try { return new StudioIdentity.Owner(UUID.fromString(values.getProperty(prefix + "-account")),
+ UUID.fromString(values.getProperty(prefix + "-character"))); }
+ catch (IllegalArgumentException | NullPointerException ex) { throw new Conflict("Invalid journal owner"); }
+ }
+
+ static Properties read(Path file) throws IOException { return read(file, Files.exists(file) ? Files.readAllBytes(file) : null); }
+
+ private static Properties read(Path file, byte[] journalBytes) throws IOException {
+ class Journal extends Properties {
+ boolean loading;
+ @Override public synchronized Object put(Object key, Object value) {
+ if (loading && containsKey(key)) throw new IllegalArgumentException("Duplicate ownership XML key");
+ return super.put(key, value);
+ }
+ }
+ Journal result = new Journal();
+ if (journalBytes != null) try (var input = new ByteArrayInputStream(journalBytes)) {
+ result.loading = true;
+ try { result.loadFromXML(input); }
+ catch (java.util.InvalidPropertiesFormatException | IllegalArgumentException ex) { throw new Conflict("Unreadable ownership journal"); }
+ finally { result.loading = false; }
+ if (!"1".equals(result.getProperty("format"))) throw new Conflict("Unsupported ownership journal format");
+ String kind = result.getProperty("kind");
+ if (!"migration".equals(kind) && !"estate".equals(kind)) throw new Conflict("Unknown ownership journal kind");
+ try {
+ if (!file.getParent().getFileName().toString().equals("estate".equals(kind) ? "estates" : "migration"))
+ throw new Conflict("Ownership journal kind differs from its path");
+ if ("estate".equals(kind)) {
+ owner(result, "from");
+ if (!file.getFileName().toString().equals(UUID.fromString(result.getProperty("death")) + ".xml"))
+ throw new Conflict("Estate death differs from its journal path");
+ } else if (result.getProperty("from-account") != null) {
+ UUID account = UUID.fromString(result.getProperty("from-account"));
+ if (!file.getFileName().toString().equals(account + ".xml") || !account.equals(owner(result, "to").account()))
+ throw new Conflict("Legacy account differs from its journal path or reviewed heir");
+ } else if (!file.getFileName().toString().matches("unrecognized-[0-9a-f]{64}\\.xml") || !ids(result).isEmpty()) {
+ throw new Conflict("Unattributed ownership journal needs staff review");
+ }
+ for (String id : ids(result)) {
+ UUID.fromString(id);
+ if (!List.of("planned", "published", "moved").contains(result.getProperty(id + ".state")))
+ throw new Conflict("Unknown ownership publication state");
+ for (String part : List.of("before", "after")) {
+ byte[] bytes = Base64.getDecoder().decode(result.getProperty(id + "." + part));
+ if (!hash(bytes).equals(result.getProperty(id + "." + part + "-hash")))
+ throw new Conflict("Ownership journal payload hash differs");
+ }
+ StudioIdentity.Owner to = owner(result, id + ".to");
+ var after = yaml(Base64.getDecoder().decode(result.getProperty(id + ".after")));
+ if (!id.equals(after.get("id")) || !to.account().toString().equals(after.get("owner-account"))
+ || !to.character().toString().equals(after.get("owner-character")))
+ throw new Conflict("Planned authority differs from its payload");
+ if (result.getProperty(id + ".source") == null || result.getProperty(id + ".target") == null)
+ throw new Conflict("Ownership publication path missing");
+ var before = yaml(Base64.getDecoder().decode(result.getProperty(id + ".before")));
+ if (!Integer.valueOf(StudioStore.FORMAT).equals(before.get("format"))
+ || !Integer.valueOf(StudioStore.FORMAT).equals(after.get("format")))
+ throw new Conflict("Unsupported project payload schema");
+ if (!id.equals(before.get("id"))) throw new Conflict("Planned source ID differs from its payload");
+ Path expectedSource;
+ if ("estate".equals(kind)) {
+ StudioIdentity.Owner from = owner(result, "from");
+ if (!from.equals(owner(result, id + ".from"))
+ || !from.account().toString().equals(before.get("owner-account"))
+ || !from.character().toString().equals(before.get("owner-character")))
+ throw new Conflict("Planned source authority differs from its payload");
+ if (!Long.toString(validateDecision(result.getProperty(id + ".decision"), UUID.fromString(result.getProperty("death")), from, to))
+ .equals(result.getProperty(id + ".revision"))) throw new Conflict("Project revision differs from its captured decision");
+ expectedSource = Path.of("character-projects", from.character().toString(), id + ".yml");
+ } else {
+ UUID account = UUID.fromString(result.getProperty("from-account"));
+ if (!account.toString().equals(before.get("owner")) || !to.equals(owner(result, "to")))
+ throw new Conflict("Planned legacy authority differs from its payload");
+ expectedSource = Path.of("projects", account.toString(), id + ".yml");
+ }
+ var expected = new java.util.LinkedHashMap<>(before);
+ expected.put("owner-account", to.account().toString()); expected.put("owner-character", to.character().toString());
+ if (!java.util.Arrays.equals(RawYaml.bytes(expected), RawYaml.bytes(after)))
+ throw new Conflict("Ownership plan changes project content or unrelated metadata");
+ if (expectedSource.equals(Path.of("character-projects", to.character().toString(), id + ".yml")))
+ throw new Conflict("Source and destination refer to the same project");
+ if (!expectedSource.equals(Path.of(result.getProperty(id + ".source")))
+ || !Path.of("character-projects", to.character().toString(), id + ".yml").equals(Path.of(result.getProperty(id + ".target"))))
+ throw new Conflict("Project path differs from its planned owner");
+ }
+ } catch (IllegalArgumentException | NullPointerException ex) { throw new Conflict("Malformed ownership publication record"); }
+ }
+ return result;
+ }
+
+ static void write(Path file, Properties journal) throws IOException {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ journal.storeToXML(output, "Studio ownership format 1", StandardCharsets.UTF_8);
+ DurableFiles.write(file, output.toByteArray(), true);
+ }
+
+ static String hash(byte[] bytes) {
+ try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
+ catch (NoSuchAlgorithmException ex) { throw new AssertionError(ex); }
+ }
+
+ private static java.util.Map yaml(byte[] bytes) throws IOException {
+ try { return RawYaml.read(bytes); }
+ catch (IOException ex) { throw new Conflict("Unreadable project YAML: " + ex.getMessage()); }
+ }
+
+ private static List files(Path folder) throws IOException {
+ if (!Files.isDirectory(folder)) return List.of();
+ try (var paths = Files.list(folder)) { return paths.sorted().toList(); }
+ }
+
+ private static List projects(Path folder) throws IOException {
+ return files(folder).stream().filter(file -> file.getFileName().toString().endsWith(".yml")).toList();
+ }
+
+ static List ids(Properties journal) {
+ return journal.stringPropertyNames().stream().filter(key -> key.endsWith(".state")
+ && !key.startsWith("held.") && !key.startsWith("held-source."))
+ .map(key -> key.substring(0, key.length() - 6)).sorted().toList();
+ }
+
+ private static void removeEntry(Properties journal, String id) {
+ new ArrayList<>(journal.stringPropertyNames()).stream().filter(key -> key.startsWith(id + ".")).forEach(journal::remove);
+ }
+}
diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java
index 787ab53..c907ece 100644
--- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java
@@ -43,6 +43,9 @@ public final class StudioService {
private final InstrumentPlugin plugin;
private final InstrumentManager manager;
private final StudioStore store;
+ private final StudioIdentity identity;
+ private final StudioOwnership ownership;
+ private final Map owners = new HashMap<>();
private final StudioSettings settings;
private final StudioStation station;
private final DiscItems discs;
@@ -71,6 +74,7 @@ protected boolean removeEldestEntry(Map.Entry eldest) {
private final ExecutorService checkpointWriter;
private long clock;
private boolean closed;
+ private boolean migrationAttempted;
private volatile long shutdownDeadline;
public StudioService(InstrumentPlugin plugin, InstrumentManager manager) throws IOException {
@@ -97,17 +101,29 @@ private static YamlConfiguration loadSettings(InstrumentPlugin plugin) throws IO
StudioService(InstrumentPlugin plugin, InstrumentManager manager, StudioStore store, StudioSettings settings,
StudioStation station, DiscItems discs) {
+ this(plugin, manager, store, settings, station, discs, StudioIdentity.load());
+ }
+
+ StudioService(InstrumentPlugin plugin, InstrumentManager manager, StudioStore store, StudioSettings settings,
+ StudioStation station, DiscItems discs, StudioIdentity identity) {
this(plugin, manager, store, settings, station, discs, Executors.newSingleThreadExecutor(
- Thread.ofPlatform().daemon(true).name("musical-instruments-checkpoints").factory()));
+ Thread.ofPlatform().daemon(true).name("musical-instruments-checkpoints").factory()), identity);
}
StudioService(InstrumentPlugin plugin, InstrumentManager manager, StudioStore store, StudioSettings settings,
StudioStation station, DiscItems discs, ExecutorService checkpointWriter) {
+ this(plugin, manager, store, settings, station, discs, checkpointWriter, StudioIdentity.load());
+ }
+
+ StudioService(InstrumentPlugin plugin, InstrumentManager manager, StudioStore store, StudioSettings settings,
+ StudioStation station, DiscItems discs, ExecutorService checkpointWriter, StudioIdentity identity) {
this.plugin = plugin;
this.manager = manager;
this.settings = settings;
this.station = station;
this.store = store;
+ this.identity = identity;
+ this.ownership = new StudioOwnership(store);
this.discs = discs;
this.checkpointWriter = checkpointWriter;
task = plugin.getServer().getScheduler().runTaskTimer(plugin, this::tick, 1L, 1L);
@@ -117,6 +133,63 @@ private static YamlConfiguration loadSettings(InstrumentPlugin plugin) throws IO
public StudioSettings settings() { return settings; }
public StudioStation station() { return station; }
+ /** The full ownership check for one change; callers pass the token on rather than checking again. */
+ public StudioIdentity.Token token(Player player) {
+ StudioIdentity.Token token = session(player);
+ try {
+ ownership.requireWritable(token.owner().character());
+ }
+ catch (IOException ex) { throw new IllegalArgumentException("Studio ownership needs recovery: " + ex.getMessage(), ex); }
+ return token;
+ }
+
+ // Reads need only the current session: every change, menu and dialog first passes token().
+ private StudioIdentity.Token session(Player player) {
+ StudioIdentity.Token token = identity.current(player).orElseThrow(
+ () -> new IllegalArgumentException("Choose an active character before using the recording studio"));
+ if (!current(token, player)) throw new IllegalArgumentException("Your character session changed");
+ owners.put(token.owner().character(), token.owner());
+ if (!migrationAttempted) migrateOwnership();
+ return token;
+ }
+
+ // Keeps the recording refusal ahead of the ownership check, as requireIdle always did.
+ private StudioIdentity.Token idleToken(Player player) {
+ requireNotRecording(player);
+ return token(player);
+ }
+
+ public boolean current(StudioIdentity.Token token, Player player) {
+ return token != null && token.owner().account().equals(player.getUniqueId()) && identity.current(token);
+ }
+
+ public void startOwnership(StudioMenu menu) {
+ plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
+ migrateOwnership();
+ }, 1L, 72_000L);
+ if (identity instanceof CharacterStudioBridge) CharacterStudioBridge.start(plugin, this, menu);
+ StudioEstate.startIfAvailable(plugin, this, ownership);
+ }
+
+ private void migrateOwnership() {
+ if (!identity.heirsReady()) return;
+ migrationAttempted = true;
+ try { ownership.migrate(identity); }
+ catch (IOException ex) {
+ var causes = new java.util.StringJoiner("; "); causes.add(ex.getMessage());
+ for (Throwable other : ex.getSuppressed()) causes.add(other.getMessage());
+ plugin.getLogger().warning("Studio migration held: " + causes);
+ }
+ }
+
+ boolean quiescent(UUID character) {
+ completeSaves();
+ return !pendingSaves.containsKey(character) && !unsaved.contains(character)
+ && recordings.values().stream().noneMatch(capture -> capture.token.owner().character().equals(character));
+ }
+
+ void transferred(UUID character) { projects.remove(character); owners.remove(character); }
+
public record Activity(int track, int seconds, int notes, int countdown, boolean previewing) {
public boolean recording() { return track > 0; }
}
@@ -131,19 +204,25 @@ public Activity activity(Player player) {
capture.notes.size(), countdown, false);
}
- public Project project(Player player) throws IOException {
- Project project = projects.get(player.getUniqueId());
+ public Project project(Player player) throws IOException { return project(session(player)); }
+
+ private Project project(StudioIdentity.Token token) throws IOException {
+ UUID owner = token.owner().character();
+ Project project = projects.get(owner);
if (project == null) {
- project = store.project(player.getUniqueId()).orElse(null);
+ project = store.project(owner).orElse(null);
if (project != null) {
- projects.put(player.getUniqueId(), project);
+ requireOwner(token, project);
+ projects.put(owner, project);
}
}
return project;
}
- public Project requireProject(Player player) throws IOException {
- Project project = project(player);
+ public Project requireProject(Player player) throws IOException { return requireProject(session(player)); }
+
+ private Project requireProject(StudioIdentity.Token token) throws IOException {
+ Project project = project(token);
if (project == null) {
throw new IllegalArgumentException("Create a project with /music new first");
}
@@ -151,76 +230,94 @@ public Project requireProject(Player player) throws IOException {
}
public void create(Player player, String title, boolean reset) throws IOException {
- requireSaved(player);
- Project previous = project(player);
+ StudioIdentity.Token token = idleToken(player);
+ requireSaved(player, token);
+ Project previous = project(token);
// Without an active project to replace, a reset creates another project.
- if (!reset || previous == null) requireProjectSpace(player);
- persist(player); // A project whose last write failed is written before another replaces it.
+ if (!reset || previous == null) requireProjectSpace(token);
+ persist(player, token); // A project whose last write failed is written before another replaces it.
UUID id = reset && previous != null ? previous.song().id() : UUID.randomUUID();
- save(player, new Project(new Song(id, player.getUniqueId(), player.getName(), title, List.of()),
+ save(player, token, new Project(new Song(id, player.getUniqueId(), player.getName(), title, List.of()),
null, 100, true));
stopPreview(player);
}
- public void requireProjectSpace(Player player) throws IOException {
- if (listProjects(player).size() >= MAX_PROJECTS) {
+ public void requireProjectSpace(Player player) throws IOException { requireProjectSpace(session(player)); }
+
+ private void requireProjectSpace(StudioIdentity.Token token) throws IOException {
+ if (listProjects(token).size() >= MAX_PROJECTS) {
throw new IllegalArgumentException("Your studio is full (36 projects). Delete an old project first: right-click its song in the library");
}
}
/** Loads only the requested project; a cached version may contain changes not yet written. */
- public Project project(Player player, UUID id) throws IOException {
- Project current = projects.get(player.getUniqueId());
+ public Project project(Player player, UUID id) throws IOException { return project(session(player), id); }
+
+ private Project project(StudioIdentity.Token token, UUID id) throws IOException {
+ UUID owner = token.owner().character();
+ Project current = projects.get(owner);
if (current != null && current.song().id().equals(id)) return current;
- return store.project(player.getUniqueId(), id);
+ Project loaded = store.project(owner, id);
+ requireOwner(token, loaded);
+ return loaded;
}
/** Deletes the project only if it still matches what the player confirmed. */
public void deleteProject(Player player, Project expected) throws IOException {
- requireSaved(player); // A queued write would bring the deleted project back.
- requireOwner(player, expected);
+ StudioIdentity.Token token = idleToken(player);
+ UUID owner = token.owner().character();
+ requireSaved(player, token); // A queued write would bring the deleted project back.
+ requireOwner(token, expected);
UUID id = expected.song().id();
// Summaries omit notes, mix and settings, so they cannot protect a stale confirmation.
- if (!project(player, id).equals(expected)) {
+ if (!project(token, id).equals(expected)) {
throw new IllegalArgumentException("The project changed. Open the delete confirmation again");
}
- store.delete(player.getUniqueId(), id);
- Project current = projects.get(player.getUniqueId());
+ store.delete(owner, id);
+ Project current = projects.get(owner);
if (current != null && current.song().id().equals(id)) {
- projects.remove(player.getUniqueId());
- unsaved.remove(player.getUniqueId());
+ projects.remove(owner);
+ unsaved.remove(owner);
stopPreview(player);
}
player.sendMessage("Deleted '" + expected.song().title() + "'. Published discs keep their music.");
}
- public List listProjects(Player player) throws IOException {
+ public List listProjects(Player player) throws IOException { return listProjects(session(player)); }
+
+ private List listProjects(StudioIdentity.Token token) throws IOException {
+ UUID owner = token.owner().character();
Map all = new LinkedHashMap<>();
- for (ProjectSummary summary : store.summaries(player.getUniqueId())) all.put(summary.id(), summary);
+ for (ProjectSummary summary : store.summaries(owner)) all.put(summary.id(), summary);
// An uncached project matches its files; a cached one may be newer than them.
- Project active = projects.get(player.getUniqueId());
+ Project active = projects.get(owner);
if (active != null) all.put(active.song().id(), ProjectSummary.of(active));
return all.values().stream().sorted(ProjectSummary.ORDER).toList();
}
public void selectProject(Player player, UUID id) throws IOException {
- Project current = project(player);
+ StudioIdentity.Token token = token(player);
+ UUID owner = token.owner().character();
+ Project current = project(token);
if (current != null && current.song().id().equals(id)) return;
- requireSaved(player); // The next project is read from its file, which a queued write could still change.
- Project next = store.project(player.getUniqueId(), id);
- persist(player);
- store.select(player.getUniqueId(), id); // Both projects already match their files.
- projects.put(player.getUniqueId(), next);
+ requireSaved(player, token); // The next project is read from its file, which a queued write could still change.
+ Project next = store.project(owner, id);
+ requireOwner(token, next);
+ persist(player, token);
+ store.select(owner, id); // Both projects already match their files.
+ projects.put(owner, next);
stopPreview(player);
}
/** Writes the project before it becomes the active one. Used when creating or replacing a project. */
- public void save(Player player, Project project) throws IOException {
- requireOwner(player, project);
- requireSaved(player);
- store.save(project); // Persist successfully before replacing the in-memory version.
- projects.put(player.getUniqueId(), project);
- unsaved.remove(player.getUniqueId());
+ public void save(Player player, Project project) throws IOException { save(player, token(player), project); }
+
+ private void save(Player player, StudioIdentity.Token token, Project project) throws IOException {
+ requireOwner(token, project);
+ requireSaved(player, token);
+ store.saveOwned(token.owner(), project); // Persist successfully before replacing the in-memory version.
+ projects.put(token.owner().character(), project);
+ unsaved.remove(token.owner().character());
}
/**
@@ -228,44 +325,61 @@ public void save(Player player, Project project) throws IOException {
* earlier write. If that write fails, the change stays in memory and is written again.
*/
public void edit(Player player, Project project) {
- requireOwner(player, project);
- requireIdle(player);
- projects.put(player.getUniqueId(), project);
- unsaved.add(player.getUniqueId());
- queueSave(project);
+ StudioIdentity.Token token = token(player);
+ UUID owner = token.owner().character();
+ requireOwner(token, project);
+ requireIdle(player, token);
+ projects.put(owner, project);
+ unsaved.add(owner);
+ queueSave(token.owner(), project);
}
- private static void requireOwner(Player player, Project project) {
- if (!project.song().owner().equals(player.getUniqueId())) {
- throw new IllegalArgumentException("This is not your project");
- }
+ private void requireOwner(StudioIdentity.Token token, Project project) {
+ try { store.requireAuthority(token.owner(), project.song().id(), project.song().owner()); }
+ catch (IOException ex) { throw new IllegalArgumentException("Could not verify your project's owner", ex); }
}
// Retries a write that failed. A project with no failed write already matches its files.
- private void persist(Player player) throws IOException {
- Project current = projects.get(player.getUniqueId());
- if (unsaved.contains(player.getUniqueId())) save(player, current);
+ private void persist(Player player, StudioIdentity.Token token) throws IOException {
+ UUID owner = token.owner().character();
+ if (unsaved.contains(owner)) save(player, token, projects.get(owner));
}
public void requireIdle(Player player) {
+ requireNotRecording(player);
+ requireIdle(player, session(player));
+ }
+
+ private void requireNotRecording(Player player) {
if (recordings.containsKey(player.getUniqueId())) {
throw new IllegalArgumentException("Stop the recording with /music stop first");
}
- CompletableFuture pending = pendingSaves.get(player.getUniqueId());
- if (pending != null && pending.isDone()) completeSave(player.getUniqueId(), pending);
+ }
+
+ private void requireIdle(Player player, StudioIdentity.Token token) {
+ requireNotRecording(player);
+ UUID owner = token.owner().character();
+ CompletableFuture pending = pendingSaves.get(owner);
+ if (pending != null && pending.isDone()) completeSave(owner, pending);
}
/** Also requires the project's files to be current, for changes that read or remove them. */
public void requireSaved(Player player) {
- requireIdle(player);
- if (pendingSaves.containsKey(player.getUniqueId())) {
+ requireNotRecording(player);
+ requireSaved(player, session(player));
+ }
+
+ private void requireSaved(Player player, StudioIdentity.Token token) {
+ requireIdle(player, token);
+ if (pendingSaves.containsKey(token.owner().character())) {
throw new IllegalArgumentException("Your project is still saving. Try again shortly");
}
}
public void record(Player player, int slot) throws IOException {
- requireIdle(player);
- Project project = requireProject(player);
+ StudioIdentity.Token token = idleToken(player);
+ requireIdle(player, token);
+ Project project = requireProject(token);
if (project.pending() != null) {
throw new IllegalArgumentException("Use /music keep or /music discard for the previous take first");
}
@@ -279,14 +393,14 @@ public void record(Player player, int slot) throws IOException {
}
stopPreview(player);
capacity();
- recordings.put(player.getUniqueId(), new Capture(project, slot, clock + settings.countInTicks()));
+ recordings.put(player.getUniqueId(), new Capture(project, slot, clock + settings.countInTicks(), token));
player.sendMessage("Recording starts in " + settings.countInTicks() / 20 + " seconds. Play with the hotbar, "
+ "or right-click your instrument for the keyboard. /music stop ends the take.");
}
public void capture(Player player, String instrument, String sound, float volume, float pitch) {
Capture capture = recordings.get(player.getUniqueId());
- if (capture == null || clock < capture.start || clock - capture.start >= settings.durationTicks()) {
+ if (capture == null || !current(capture.token, player) || clock < capture.start || clock - capture.start >= settings.durationTicks()) {
return;
}
int at = (int) (clock - capture.start);
@@ -323,8 +437,9 @@ public void stop(Player player) {
}
public void preview(Player player, boolean pending) throws IOException {
- requireIdle(player);
- Project project = requireProject(player);
+ StudioIdentity.Token token = idleToken(player);
+ requireIdle(player, token);
+ Project project = requireProject(token);
Song song = project.song();
if (pending) {
Track take = project.pending();
@@ -340,26 +455,27 @@ public void preview(Player player, boolean pending) throws IOException {
if (!song.audible()) {
throw new IllegalArgumentException("Every saved track is muted or silent. Unmute or raise a track first");
}
- previewSong(player, song);
+ previewSong(player, token, song);
player.sendMessage(pending ? "Previewing the new take with the other tracks." : "Previewing the saved mix.");
}
public void previewTrack(Player player, int slot) throws IOException {
- requireIdle(player);
- Song song = requireProject(player).song();
+ StudioIdentity.Token token = idleToken(player);
+ requireIdle(player, token);
+ Song song = requireProject(token).song();
Track track = song.track(slot);
if (track == null) throw new IllegalArgumentException("Record and keep this track first");
if (track.muted()) throw new IllegalArgumentException("Unmute this track to listen to it");
Song single = new Song(song.id(), song.owner(), song.author(), song.title(), List.of(track));
if (!single.audible()) throw new IllegalArgumentException("Raise this track's volume to listen to it");
- previewSong(player, single);
+ previewSong(player, token, single);
player.sendMessage("Listening to saved track " + slot + ".");
}
- private void previewSong(Player player, Song song) {
+ private void previewSong(Player player, StudioIdentity.Token token, Song song) {
stopPreview(player);
capacity();
- previews.put(player.getUniqueId(), new Preview(song, clock + 1));
+ previews.put(player.getUniqueId(), new Preview(song, clock + 1, token));
}
private void stopPreview(Player player) {
@@ -384,26 +500,28 @@ private void requireBlank(Player player) {
}
public void publish(Player player) throws IOException {
- requireIdle(player);
+ StudioIdentity.Token token = idleToken(player);
+ requireIdle(player, token);
requireBlank(player);
- Song edition = publishEdition(player);
+ Song edition = publishEdition(player, token);
player.getInventory().setItemInMainHand(discs.disc(edition));
player.sendMessage("Published '" + edition.title() + "'. Existing discs will keep this edition.");
}
public ItemStack publishOnDisc(Player player, ItemStack disc) throws IOException {
- requireIdle(player);
+ StudioIdentity.Token token = idleToken(player);
+ requireIdle(player, token);
if (!discs.musicDisc(disc) || disc.getAmount() != 1) {
throw new IllegalArgumentException("Pick up one music disc and place it on Publish full song");
}
- Song edition = publishEdition(player);
+ Song edition = publishEdition(player, token);
ItemStack result = discs.disc(edition, disc);
player.sendMessage("Published '" + edition.title() + "' on your disc. Other copies keep their previous music.");
return result;
}
- private Song publishEdition(Player player) throws IOException {
- Project project = requireProject(player);
+ private Song publishEdition(Player player, StudioIdentity.Token token) throws IOException {
+ Project project = requireProject(token);
if (project.pending() != null) {
throw new IllegalArgumentException("Keep or discard your pending take first");
}
@@ -418,7 +536,7 @@ private Song publishEdition(Player player) throws IOException {
throw new IllegalArgumentException("Wait " + seconds + " seconds before publishing again");
}
// No item is consumed when publication fails.
- Song edition = store.publishEdition(project.song(), settings.editionsPerDay());
+ Song edition = store.publishEdition(project.song(), player.getUniqueId(), settings.editionsPerDay());
lastPublished.put(player.getUniqueId(), clock);
editions.put(edition.id(), edition);
return edition;
@@ -455,9 +573,10 @@ public void prepareBlankFromInventory(Player player) {
}
public void publishFromInventory(Player player) throws IOException {
- requireIdle(player);
+ StudioIdentity.Token token = idleToken(player);
+ requireIdle(player, token);
DiscSlot slot = discSlot(player, true); // Check space before writing an edition or consuming a disc.
- Song edition = publishEdition(player);
+ Song edition = publishEdition(player, token);
slot.replace(discs.disc(edition));
player.sendMessage("Published '" + edition.title() + "' on a blank disc from your inventory.");
}
@@ -573,6 +692,7 @@ private void tick() {
continue; // PlayerQuitEvent persists the take while the player is still accessible.
}
Capture capture = entry.getValue();
+ if (!current(capture.token, player)) { finish(player, capture, "Recording stopped when the character changed"); continue; }
long elapsed = clock - capture.start;
if (elapsed < 0) {
if (-elapsed % 20 == 0) {
@@ -605,7 +725,7 @@ private void tick() {
if (capture.checkpoint.isCompletedExceptionally()) {
finish(player, capture, "Checkpoint failed; recording stopped to protect your take");
} else if (capture.checkpoint.isDone()) {
- capture.checkpoint = write(capture.snapshot((int) elapsed));
+ capture.checkpoint = write(capture.token.owner(), capture.snapshot((int) elapsed));
}
}
}
@@ -614,7 +734,7 @@ private void tick() {
Player player = plugin.getServer().getPlayer(entry.getKey());
Preview preview = entry.getValue();
int elapsed = (int) (clock - preview.start);
- if (player == null || elapsed >= preview.song.lengthTicks() + settings.tailTicks()) {
+ if (player == null || !current(preview.token, player) || elapsed >= preview.song.lengthTicks() + settings.tailTicks()) {
it.remove();
if (player != null) stopPrivateSounds(player);
else privateSounds.remove(entry.getKey());
@@ -688,20 +808,21 @@ private void finish(Player player, Capture capture, String reason) {
}
Project result = capture.snapshot((int) Math.max(1, Math.min(settings.durationTicks(), clock - capture.start + 1)));
// The take is kept in memory even if the disk fails; it can be reviewed while it is written.
- projects.put(player.getUniqueId(), result);
- unsaved.add(player.getUniqueId());
- queueSave(result);
+ UUID character = capture.token.owner().character();
+ projects.put(character, result);
+ unsaved.add(character);
+ queueSave(capture.token.owner(), result);
player.sendMessage(reason + ". Review the take: /music preview take, then /music keep or /music discard.");
}
- private void queueSave(Project project) {
- pendingSaves.put(project.song().owner(), write(project));
+ private void queueSave(StudioIdentity.Owner ref, Project project) {
+ pendingSaves.put(ref.character(), write(ref, project));
}
// One queued snapshot per owner, plus the single snapshot currently being written. Changes
// arriving during a write go to the tail, letting other owners save before this owner runs again.
- private CompletableFuture write(Project project) {
- UUID owner = project.song().owner();
+ private CompletableFuture write(StudioIdentity.Owner ref, Project project) {
+ UUID owner = ref.character();
synchronized (queuedSaves) {
QueuedSave queued = queuedSaves.get(owner);
if (queued != null) {
@@ -716,7 +837,7 @@ private CompletableFuture write(Project project) {
queuedSaves.remove(owner);
snapshot = next.project;
}
- saveSnapshot(snapshot);
+ saveSnapshot(ref, snapshot);
}, checkpointWriter).whenComplete((ignored, error) -> {
if (error == null) next.future.complete(null);
else next.future.completeExceptionally(error);
@@ -725,16 +846,16 @@ private CompletableFuture write(Project project) {
}
}
- private void saveSnapshot(Project project) {
+ private void saveSnapshot(StudioIdentity.Owner ref, Project project) {
try {
try {
- store.save(project);
+ store.saveOwned(ref, project);
} catch (IOException ex) {
// A failure can appear only after close has checked the pending saves. Retry it
// once on this same worker, even after shutdown(), while the close budget remains.
long deadline = shutdownDeadline;
if (deadline == 0 || System.nanoTime() - deadline >= 0) throw ex;
- store.save(project);
+ store.saveOwned(ref, project);
}
} catch (IOException ex) {
plugin.getLogger().log(Level.SEVERE, "Could not save studio project for " + project.song().owner(), ex);
@@ -744,7 +865,9 @@ private void saveSnapshot(Project project) {
private void completeSave(UUID owner, CompletableFuture pending) {
pendingSaves.remove(owner);
- Player player = plugin.getServer().getPlayer(owner);
+ StudioIdentity.Owner ref = owners.get(owner);
+ Player player = plugin.getServer().getPlayer(ref.account());
+ if (player != null && identity.current(player).map(token -> token.owner().character().equals(owner)).orElse(false) == false) player = null;
if (pending.isCompletedExceptionally()) {
// Retried by the next change, every minute, on disconnect and on shutdown.
if (player != null) player.sendMessage("Could not save your project. Your changes are kept and will be saved again; "
@@ -767,7 +890,7 @@ private void completeSaves() {
// its checkpoints and final take include the cached project, which still lacks the take and would erase it.
private void retryFailedSaves() {
for (UUID owner : unsaved) {
- if (!pendingSaves.containsKey(owner) && !recordings.containsKey(owner)) queueSave(projects.get(owner));
+ if (!pendingSaves.containsKey(owner) && recordings.values().stream().noneMatch(capture -> capture.token.owner().character().equals(owner))) queueSave(owners.get(owner), projects.get(owner));
}
}
@@ -777,12 +900,25 @@ public void quit(Player player) {
finish(player, capture, "Recording stopped on disconnect");
}
stopPreview(player);
- UUID owner = player.getUniqueId();
+ StudioIdentity.Token token = identity.current(player).orElse(null);
+ if (token == null) return;
+ UUID owner = token.owner().character();
if (pendingSaves.containsKey(owner)) return; // completeSave evicts the project once it is written.
- if (unsaved.contains(owner)) queueSave(projects.get(owner)); // Retry a write that failed.
+ if (unsaved.contains(owner)) queueSave(owners.get(owner), projects.get(owner)); // Retry a write that failed.
else projects.remove(owner); // Its files are current; the next visit reloads it.
}
+ public void end(Player player, StudioIdentity.Token ending) {
+ Capture capture = recordings.get(player.getUniqueId());
+ if (capture != null && capture.token.equals(ending)) finish(player, capture, "Recording stopped when the character session ended");
+ stopPreview(player);
+ UUID character = ending.owner().character();
+ if (!pendingSaves.containsKey(character)) {
+ if (unsaved.contains(character)) queueSave(ending.owner(), projects.get(character));
+ else projects.remove(character);
+ }
+ }
+
public void close() {
if (closed) return;
shutdownDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(SHUTDOWN_WAIT_SECONDS);
@@ -839,13 +975,15 @@ private QueuedSave(Project project) {
private static final class Capture {
private final Project project;
+ private final StudioIdentity.Token token;
private final int slot;
private final long start;
private final Timeline backing;
private final List notes = new ArrayList<>();
private CompletableFuture checkpoint = CompletableFuture.completedFuture(null);
- private Capture(Project project, int slot, long start) {
+ private Capture(Project project, int slot, long start, StudioIdentity.Token token) {
+ this.token = token;
this.project = project;
this.slot = slot;
this.start = start;
@@ -865,7 +1003,10 @@ private static final class Preview {
private final long start;
private final Timeline timeline;
- private Preview(Song song, long start) {
+ private final StudioIdentity.Token token;
+
+ private Preview(Song song, long start, StudioIdentity.Token token) {
+ this.token = token;
this.song = song;
this.start = start;
timeline = new Timeline(song, -1);
diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java
index 9821dfa..ef5698d 100644
--- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java
@@ -98,11 +98,11 @@ public List summaries(UUID owner) throws IOException {
return index(owner).values().stream().sorted(ProjectSummary.ORDER).toList();
}
- private Path directory(UUID owner) {
- return root.resolve("projects").resolve(owner.toString());
+ Path directory(UUID owner) {
+ return root.resolve("character-projects").resolve(owner.toString());
}
- private Path projectFile(UUID owner, UUID id) {
+ Path projectFile(UUID owner, UUID id) {
return directory(owner).resolve(id + ".yml");
}
@@ -256,7 +256,8 @@ private Project decodeProject(Path file, UUID owner, UUID id) throws IOException
YamlConfiguration yaml = read(file);
try {
Song song = decodeSong(yaml);
- if (!song.owner().equals(owner)) {
+ String authority = yaml.getString("owner-character");
+ if (!(authority == null ? song.owner() : UUID.fromString(authority)).equals(owner)) {
throw new IllegalArgumentException("Project owner mismatch");
}
if (!song.id().equals(id)) throw new IllegalArgumentException("Project ID mismatch");
@@ -267,6 +268,22 @@ private Project decodeProject(Path file, UUID owner, UUID id) throws IOException
}
}
+ public void requireAuthority(StudioIdentity.Owner owner, UUID id, UUID authorAccount) throws IOException {
+ Path file = projectFile(owner.character(), id);
+ if (!Files.exists(file)) {
+ if (!authorAccount.equals(owner.account())) throw new IllegalArgumentException("This is not your project");
+ return;
+ }
+ YamlConfiguration yaml = read(file);
+ String account = yaml.getString("owner-account");
+ String character = yaml.getString("owner-character");
+ if (!owner.account().toString().equals(account) || !owner.character().toString().equals(character)) {
+ throw new IllegalArgumentException("This is not your character's project");
+ }
+ }
+
+ Path root() { return root; }
+
public Song song(UUID id) throws IOException {
YamlConfiguration yaml = read(root.resolve("songs").resolve(id + ".yml"));
try {
@@ -281,22 +298,77 @@ public Song song(UUID id) throws IOException {
}
public void save(Project project) throws IOException {
- UUID owner = project.song().owner();
+ saveAt(project.song().owner(), null, project);
+ }
+
+ public void saveOwned(StudioIdentity.Owner owner, Project project) throws IOException {
+ requireAuthority(owner, project.song().id(), project.song().owner());
+ saveAt(owner.character(), owner, project);
+ }
+
+ private void saveAt(UUID owner, StudioIdentity.Owner authority, Project project) throws IOException {
Path file = projectFile(owner, project.song().id());
Files.createDirectories(file.getParent());
- write(file, encodeProject(project), true);
+ if (Files.exists(file)) read(file);
+ Map yaml = Files.exists(file) ? RawYaml.read(Files.readAllBytes(file)) : new LinkedHashMap<>();
+ preserveProject(yaml, project);
+ if (authority != null) {
+ yaml.put("owner-account", authority.account().toString());
+ yaml.put("owner-character", authority.character().toString());
+ }
+ write(file, RawYaml.bytes(yaml), true);
point(owner, project.song().id());
remember(project, file);
}
- private YamlConfiguration encodeProject(Project project) {
- YamlConfiguration yaml = encodeSong(project.song());
- yaml.set("bpm", project.bpm());
- yaml.set("metronome", project.metronome());
+ private void preserveProject(Map yaml, Project project) {
+ YamlConfiguration known = encodeSong(project.song());
+ Object oldTracks = yaml.get("tracks");
+ for (String key : known.getKeys(false)) yaml.put(key, known.get(key));
+ yaml.put("tracks", preserveTracks(yaml, oldTracks, project.song().tracks()));
+ yaml.put("bpm", project.bpm());
+ yaml.put("metronome", project.metronome());
if (project.pending() != null) {
- yaml.set("pending", encodeTrack(project.pending()));
+ Map pending = new LinkedHashMap<>();
+ if (yaml.containsKey("pending")) map(yaml.get("pending")).forEach((key, value) -> pending.put(key.toString(), value));
+ pending.putAll(encodeTrack(project.pending()));
+ yaml.put("pending", pending);
+ } else {
+ // Keep unknown extension fields when a pending take is removed.
+ Object old = yaml.get("pending");
+ if (old != null) {
+ Map extra = new LinkedHashMap<>();
+ map(old).forEach((key, value) -> { if (!TRACK_KEYS.contains(key.toString())) extra.put(key.toString(), value); });
+ if (!extra.isEmpty()) yaml.put("retained-pending", extra);
+ }
+ yaml.remove("pending");
}
- return yaml;
+ }
+
+ private static final java.util.Set TRACK_KEYS = java.util.Set.of("slot", "length", "gain", "muted", "names", "notes");
+
+ private List