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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/compatibility/tlibs-2.3.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>net.tfminecraft</groupId>
<artifactId>studio-compatibility-fixture</artifactId>
<version>main-SNAPSHOT</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<tlibs.version>2.3.0</tlibs.version>
</properties>
<dependencies>
<dependency>
<groupId>me.plugins</groupId>
<artifactId>tlibs</artifactId>
<version>${tlibs.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
13 changes: 13 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion .github/workflows/maven-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,22 @@
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<tlibs.version>2.6.0</tlibs.version>
</properties>

<dependencies>
<dependency>
<groupId>me.plugins</groupId>
<artifactId>tlibs</artifactId>
<version>${tlibs.version}</version>
<scope>provided</scope>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Token> 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<Owner> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<UUID, Map.Entry<Long, Outcome>> 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<Long, Outcome> 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()); }
}
Original file line number Diff line number Diff line change
@@ -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); }
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<String, Object> 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<String, Object> values) { return new Yaml().dump(values).getBytes(StandardCharsets.UTF_8); }
}
Loading
Loading