From c497c9f4f1eb1392a989d50a9344ce17fe3729a2 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Tue, 13 Oct 2026 21:52:14 +0000
Subject: [PATCH 1/7] feat(studio): migrate editable projects to character
ownership
---
.github/compatibility/tlibs-2.3.xml | 21 +
.github/workflows/build.yml | 13 +
.github/workflows/maven-release.yml | 13 +
pom.xml | 13 +
.../musicalinstruments/InstrumentPlugin.java | 1 +
.../studio/CharacterStudioBridge.java | 53 ++
.../studio/CharacterStudioEstate.java | 82 ++++
.../studio/DurableFiles.java | 57 +++
.../musicalinstruments/studio/RawYaml.java | 34 ++
.../studio/StudioDialogs.java | 3 +-
.../studio/StudioEstate.java | 19 +
.../studio/StudioIdentity.java | 33 ++
.../musicalinstruments/studio/StudioMenu.java | 22 +-
.../studio/StudioOwnership.java | 351 +++++++++++++
.../studio/StudioService.java | 189 +++++--
.../studio/StudioStore.java | 124 +++--
src/main/resources/plugin.yml | 2 +-
.../InstrumentPluginTest.java | 8 +
.../studio/StudioBridgeTest.java | 129 +++++
.../studio/StudioCharacterTest.java | 85 ++++
.../studio/StudioDialogWorkflowTest.java | 14 +
.../studio/StudioEstateTest.java | 106 ++++
.../studio/StudioMenuTest.java | 21 +
.../studio/StudioOwnershipTest.java | 464 ++++++++++++++++++
.../studio/StudioSaveQueueTest.java | 31 +-
.../studio/StudioServiceTest.java | 158 ++++--
.../studio/StudioStoreRecoveryTest.java | 8 +-
.../studio/StudioStoreTest.java | 28 +-
.../studio/TestIdentity.java | 26 +
tools/studio-ownership.py | 122 +++++
tools/test_studio_ownership.py | 83 ++++
31 files changed, 2148 insertions(+), 165 deletions(-)
create mode 100644 .github/compatibility/tlibs-2.3.xml
create mode 100644 src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioBridge.java
create mode 100644 src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioEstate.java
create mode 100644 src/main/java/net/tfminecraft/musicalinstruments/studio/DurableFiles.java
create mode 100644 src/main/java/net/tfminecraft/musicalinstruments/studio/RawYaml.java
create mode 100644 src/main/java/net/tfminecraft/musicalinstruments/studio/StudioEstate.java
create mode 100644 src/main/java/net/tfminecraft/musicalinstruments/studio/StudioIdentity.java
create mode 100644 src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java
create mode 100644 src/test/java/net/tfminecraft/musicalinstruments/studio/StudioBridgeTest.java
create mode 100644 src/test/java/net/tfminecraft/musicalinstruments/studio/StudioCharacterTest.java
create mode 100644 src/test/java/net/tfminecraft/musicalinstruments/studio/StudioEstateTest.java
create mode 100644 src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipTest.java
create mode 100644 src/test/java/net/tfminecraft/musicalinstruments/studio/TestIdentity.java
create mode 100755 tools/studio-ownership.py
create mode 100644 tools/test_studio_ownership.py
diff --git a/.github/compatibility/tlibs-2.3.xml b/.github/compatibility/tlibs-2.3.xml
new file mode 100644
index 0000000..2851e42
--- /dev/null
+++ b/.github/compatibility/tlibs-2.3.xml
@@ -0,0 +1,21 @@
+
+
+ 4.0.0
+ net.tfminecraft
+ studio-compatibility-fixture
+ main-SNAPSHOT
+
+ 21
+ UTF-8
+
+
+
+ me.plugins
+ tlibs
+ 2.3.0
+ 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..00e5ad6 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 }}
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..4cd92ab
--- /dev/null
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioEstate.java
@@ -0,0 +1,82 @@
+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) {
+ 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 = 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 = 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() {
+ for (EstateDecision decision : Estates.decided()) {
+ try {
+ if (!Estates.guardHolds(decision)) continue;
+ Outcome outcome = settle(decision);
+ 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); }
+ }
+ }
+
+ 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..3b46d36 100644
--- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioDialogs.java
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioDialogs.java
@@ -149,11 +149,12 @@ private DialogAction action(Player owner, Location station, AtomicBoolean used,
private DialogAction action(Player owner, Location station, AtomicBoolean used, Response response, Reopen destination) {
UUID ownerId = owner.getUniqueId();
+ StudioIdentity.Token token = studio.token(owner);
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..80c7d7d 100644
--- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioMenu.java
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioMenu.java
@@ -53,7 +53,7 @@ public StudioMenu(InstrumentPlugin plugin, StudioService studio) {
private void open(Player player, Block station, Screen screen, int page) throws IOException {
studio.station().require(player, station);
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(studio.token(player), 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 +189,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 +200,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 +269,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 +298,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 +315,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 +337,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 +350,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..89c633a
--- /dev/null
+++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java
@@ -0,0 +1,351 @@
+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 Stamp(long size, java.nio.file.attribute.FileTime modified, Object key) {}
+ private record Locks(Stamp stamp, java.util.Set characters) {}
+ private final java.util.Map lockCache = 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;
+ for (Path folder : files(legacy)) {
+ if (!Files.isDirectory(folder)) { holdUnrecognized(folder); continue; }
+ UUID account;
+ try { account = UUID.fromString(folder.getFileName().toString()); }
+ catch (IllegalArgumentException ex) { holdUnrecognized(folder); continue; }
+ 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) continue;
+ 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)));
+ write(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");
+ write(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));
+ }
+ write(file, journal);
+ }
+
+ List transfer(UUID death, String exactDecision, StudioIdentity.Owner from, StudioIdentity.Owner to,
+ BooleanSupplier guard) throws IOException {
+ if (from.equals(to)) throw new Conflict("Estate recipient is the testator");
+ Path file = root.resolve("ownership/estates").resolve(death + ".xml");
+ Properties journal = read(file);
+ 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 (!death.toString().equals(journal.getProperty("death")) || !from.equals(owner(journal, "from")))
+ throw new Conflict("Estate journal identity differs from this death");
+ 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);
+ } else if (!to.equals(owner(journal, id + ".to"))) {
+ throw new Conflict("The planned recipient differs from the current exact decision");
+ }
+ }
+ 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);
+ 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);
+ }
+ write(file, journal);
+ for (String id : ids(journal)) {
+ if (!"moved".equals(journal.getProperty(id + ".state"))) 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 {
+ Path file = root.resolve("ownership/estates").resolve(death + ".xml");
+ Properties journal = read(file);
+ if (!journal.isEmpty() && (!death.toString().equals(journal.getProperty("death")) || !from.equals(owner(journal, "from"))))
+ throw new Conflict("Estate journal identity differs from this death");
+ 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);
+ }
+ }
+ if (!journal.isEmpty()) write(file, journal);
+ return ids(journal).stream().filter(id -> "moved".equals(journal.getProperty(id + ".state"))).toList();
+ }
+
+ 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");
+ StudioIdentity.Owner to = owner(journal, id + ".to");
+ if (!target.equals(store.projectFile(to.character(), UUID.fromString(id)).normalize()))
+ throw new Conflict("Destination path differs from its planned character");
+ 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 (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");
+ write(file, journal);
+ if (removeSource) {
+ if (!guard.getAsBoolean()) throw new IOException("Estate decision changed before source removal");
+ DurableFiles.delete(source);
+ }
+ journal.setProperty(id + ".state", "moved");
+ write(file, journal);
+ }
+
+ void requireWritable(UUID character) throws IOException {
+ for (String kind : List.of("migration", "estates")) for (Path file : files(root.resolve("ownership").resolve(kind))) {
+ var attributes = Files.readAttributes(file, java.nio.file.attribute.BasicFileAttributes.class);
+ Stamp stamp = new Stamp(attributes.size(), attributes.lastModifiedTime(), attributes.fileKey());
+ Locks cached = lockCache.get(file);
+ if (cached == null || !stamp.equals(cached.stamp())) {
+ Properties journal = read(file);
+ java.util.Set pending = new java.util.HashSet<>();
+ for (String id : ids(journal)) if (!"moved".equals(journal.getProperty(id + ".state"))) {
+ pending.add(owner(journal, id + ".to").character());
+ if ("estate".equals(journal.getProperty("kind"))) pending.add(owner(journal, "from").character());
+ }
+ cached = new Locks(stamp, java.util.Set.copyOf(pending));
+ lockCache.put(file, cached);
+ }
+ if (cached.characters().contains(character)) throw new Conflict("Studio ownership publication needs recovery: " + file);
+ }
+ }
+
+ 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;
+ Properties journal = read(root.resolve("ownership/migration").resolve(from.account() + ".xml"));
+ if (journal.isEmpty() || !"true".equals(journal.getProperty("complete")))
+ throw new IOException("The account's reviewed legacy project migration is not complete");
+ if (from.character().toString().equals(journal.getProperty("to-character"))
+ && journal.stringPropertyNames().stream().anyMatch(key -> key.startsWith("held.")))
+ throw new Conflict("Legacy projects for this character remain held for staff");
+ }
+
+ private Path path(String relative) throws Conflict {
+ Path result = root.resolve(relative).normalize();
+ if (!result.startsWith(root.normalize())) throw new Conflict("Invalid ownership journal path");
+ return result;
+ }
+
+ 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 {
+ Properties result = new Properties();
+ if (Files.exists(file)) try (var input = Files.newInputStream(file)) {
+ try { result.loadFromXML(input); }
+ catch (java.util.InvalidPropertiesFormatException ex) { throw new Conflict("Unreadable ownership journal"); }
+ 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 {
+ 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 (!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");
+ 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");
+ }
+ if (!expectedSource.equals(Path.of(result.getProperty(id + ".source"))))
+ throw new Conflict("Source 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..3ed5fbb 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,45 @@ private static YamlConfiguration loadSettings(InstrumentPlugin plugin) throws IO
public StudioSettings settings() { return settings; }
public StudioStation station() { return station; }
+ public StudioIdentity.Token token(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());
+ try {
+ if (!migrationAttempted && identity.heirsReady()) {
+ ownership.migrate(identity);
+ migrationAttempted = true;
+ }
+ ownership.requireWritable(token.owner().character());
+ }
+ catch (IOException ex) { throw new IllegalArgumentException("Studio ownership needs recovery: " + ex.getMessage(), ex); }
+ return token;
+ }
+
+ public boolean current(StudioIdentity.Token token, Player player) {
+ return token != null && token.owner().account().equals(player.getUniqueId()) && identity.current(token);
+ }
+
+ private UUID owner(Player player) { return token(player).owner().character(); }
+
+ public void startOwnership(StudioMenu menu) {
+ plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
+ try { ownership.migrate(identity); migrationAttempted = identity.heirsReady(); }
+ catch (IOException ex) { plugin.getLogger().warning("Studio migration held: " + ex.getMessage()); }
+ }, 1L, 72_000L);
+ if (identity instanceof CharacterStudioBridge) CharacterStudioBridge.start(plugin, this, menu);
+ StudioEstate.startIfAvailable(plugin, this, ownership);
+ }
+
+ 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; }
}
@@ -132,11 +187,12 @@ public Activity activity(Player player) {
}
public Project project(Player player) throws IOException {
- Project project = projects.get(player.getUniqueId());
+ Project project = projects.get(owner(player));
if (project == null) {
- project = store.project(player.getUniqueId()).orElse(null);
+ project = store.project(owner(player)).orElse(null);
if (project != null) {
- projects.put(player.getUniqueId(), project);
+ requireOwner(player, project);
+ projects.put(owner(player), project);
}
}
return project;
@@ -170,9 +226,11 @@ public void requireProjectSpace(Player player) throws IOException {
/** 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());
+ Project current = projects.get(owner(player));
if (current != null && current.song().id().equals(id)) return current;
- return store.project(player.getUniqueId(), id);
+ Project loaded = store.project(owner(player), id);
+ requireOwner(player, loaded);
+ return loaded;
}
/** Deletes the project only if it still matches what the player confirmed. */
@@ -184,11 +242,11 @@ public void deleteProject(Player player, Project expected) throws IOException {
if (!project(player, 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(player), id);
+ Project current = projects.get(owner(player));
if (current != null && current.song().id().equals(id)) {
- projects.remove(player.getUniqueId());
- unsaved.remove(player.getUniqueId());
+ projects.remove(owner(player));
+ unsaved.remove(owner(player));
stopPreview(player);
}
player.sendMessage("Deleted '" + expected.song().title() + "'. Published discs keep their music.");
@@ -196,9 +254,9 @@ public void deleteProject(Player player, Project expected) throws IOException {
public List listProjects(Player player) throws IOException {
Map all = new LinkedHashMap<>();
- for (ProjectSummary summary : store.summaries(player.getUniqueId())) all.put(summary.id(), summary);
+ for (ProjectSummary summary : store.summaries(owner(player))) 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(player));
if (active != null) all.put(active.song().id(), ProjectSummary.of(active));
return all.values().stream().sorted(ProjectSummary.ORDER).toList();
}
@@ -207,10 +265,11 @@ public void selectProject(Player player, UUID id) throws IOException {
Project current = project(player);
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);
+ Project next = store.project(owner(player), id);
+ requireOwner(player, next);
persist(player);
- store.select(player.getUniqueId(), id); // Both projects already match their files.
- projects.put(player.getUniqueId(), next);
+ store.select(owner(player), id); // Both projects already match their files.
+ projects.put(owner(player), next);
stopPreview(player);
}
@@ -218,9 +277,9 @@ public void selectProject(Player player, UUID id) throws IOException {
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());
+ store.saveOwned(token(player).owner(), project); // Persist successfully before replacing the in-memory version.
+ projects.put(owner(player), project);
+ unsaved.remove(owner(player));
}
/**
@@ -230,35 +289,35 @@ public void save(Player player, Project project) throws IOException {
public void edit(Player player, Project project) {
requireOwner(player, project);
requireIdle(player);
- projects.put(player.getUniqueId(), project);
- unsaved.add(player.getUniqueId());
- queueSave(project);
+ projects.put(owner(player), project);
+ unsaved.add(owner(player));
+ queueSave(token(player).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(Player player, Project project) {
+ StudioIdentity.Owner ref = token(player).owner();
+ try { store.requireAuthority(ref, 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);
+ Project current = projects.get(owner(player));
+ if (unsaved.contains(owner(player))) save(player, current);
}
public void requireIdle(Player player) {
- if (recordings.containsKey(player.getUniqueId())) {
+ if (recordings.containsKey(owner(player))) {
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);
+ CompletableFuture pending = pendingSaves.get(owner(player));
+ if (pending != null && pending.isDone()) completeSave(owner(player), 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())) {
+ if (pendingSaves.containsKey(owner(player))) {
throw new IllegalArgumentException("Your project is still saving. Try again shortly");
}
}
@@ -279,14 +338,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)));
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);
@@ -359,7 +418,7 @@ public void previewTrack(Player player, int slot) throws IOException {
private void previewSong(Player player, Song song) {
stopPreview(player);
capacity();
- previews.put(player.getUniqueId(), new Preview(song, clock + 1));
+ previews.put(player.getUniqueId(), new Preview(song, clock + 1, token(player)));
}
private void stopPreview(Player player) {
@@ -418,7 +477,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;
@@ -573,6 +632,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 +665,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 +674,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 +748,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 +777,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 +786,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 +805,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 +830,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 +840,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 +915,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 +943,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