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
4 changes: 4 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ jobs:
with:
distribution: temurin
java-version: '21'
- name: Install shared plugin dependencies
uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74
with:
mode: pinned
- name: Prepare pinned dependencies
env:
GH_TOKEN: ${{ secrets.DEPS_TOKEN }}
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/maven-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ jobs:
}
[[ "${TAG^^}" != *SNAPSHOT* ]] || exit 1

- name: Install shared plugin dependencies
uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74
with:
mode: pinned
- name: Prepare pinned dependencies
env:
GH_TOKEN: ${{ secrets.DEPS_TOKEN }}
Expand Down Expand Up @@ -75,7 +79,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
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@
<url>https://github.com/TF-Minecraft/BreedingBuddies</url>

<properties>
<tlibs.version>2.6.0</tlibs.version>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<argLine></argLine>
</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>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
Expand Down
37 changes: 37 additions & 0 deletions src/main/java/net/tfminecraft/breedingbuddies/AnimalCodec.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package net.tfminecraft.breedingbuddies;

import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import net.tfminecraft.tlibs.character.CharacterRef;
import java.util.*;

final class AnimalCodec {
private AnimalCodec() {}
static JSONObject patch(JSONObject raw, FarmAnimal animal) {
JSONObject row = raw == null ? new JSONObject() : AnimalFiles.copy(raw);
row.put("uuid", animal.getUuid().toString()); row.put("name", animal.getName());
JSONArray slots = new JSONArray();
for (CharacterRef owner : animal.getOwnerRefs().values()) {
JSONObject slot = AnimalOwnershipStore.ref(owner);
if (raw != null) for (Object existing : (JSONArray) raw.get("characterOwners")) if (owner.characterId().toString().equals(((JSONObject) existing).get("character"))) slot = AnimalFiles.copy((JSONObject) existing);
slots.add(slot);
}
row.put("characterOwners", slots);
if (!row.containsKey("heldOwners")) row.put("heldOwners", new JSONArray());
row.put("stableNeeded", animal.isStableNeeded()); row.put("friendshipPoints", (long) animal.getFriendshipPoints()); row.put("geneticPoints", (long) animal.getGeneticPoints());
row.put("state", animal.getState().toString()); row.put("fed", animal.getFed()); row.put("cared", animal.getCared()); row.put("sleptInStable", animal.isSleptInStable());
row.put("spaceNeeded", animal.isSpaceNeeded()); row.put("waterNeeded", animal.isWaterNeeded()); row.put("daysOut", (long) animal.getDaysOut());
row.put("lastRewardCollected", animal.getLastRewardCollected() == null ? null : animal.getLastRewardCollected().getTime());
row.put("lastUpdated", animal.getLastUpdated() == null ? null : animal.getLastUpdated().getTime()); return row;
}
static FarmAnimal load(JSONObject row) {
FarmAnimal animal = new FarmAnimal(UUID.fromString((String) row.get("uuid")), (String) row.get("name"), new HashSet<>());
for (CharacterRef owner : AnimalOwnershipStore.owners(row)) { animal.getOwnerRefs().put(owner.characterId(), owner); animal.getOwnersUuids().add(owner.characterId()); }
animal.setStableNeeded((boolean) row.get("stableNeeded")); animal.setFriendshipPoints(((Number) row.get("friendshipPoints")).intValue());
animal.setGeneticPoints(((Number) row.get("geneticPoints")).intValue()); animal.setState(AnimalStates.valueOf((String) row.get("state")));
animal.setFed((boolean) row.get("fed")); animal.setCared((boolean) row.get("cared")); animal.setSleptInStable((boolean) row.get("sleptInStable"));
animal.setSpaceNeeded((boolean) row.get("spaceNeeded")); animal.setWaterNeeded((boolean) row.get("waterNeeded")); animal.setDaysOut(((Number) row.get("daysOut")).intValue());
if (row.get("lastRewardCollected") != null) animal.setLastRewardCollected(new Date(((Number) row.get("lastRewardCollected")).longValue()));
if (row.get("lastUpdated") != null) animal.setLastUpdated(new Date(((Number) row.get("lastUpdated")).longValue())); return animal;
}
}
117 changes: 117 additions & 0 deletions src/main/java/net/tfminecraft/breedingbuddies/AnimalEstates.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package net.tfminecraft.breedingbuddies;

import net.tfminecraft.tlibs.character.CharacterRef;
import net.tfminecraft.tlibs.character.estate.*;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.ServicePriority;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import java.io.IOException;
import java.nio.file.Files;
import java.util.*;

/** Optional estate API; never loaded until the capability probe succeeds. */
final class AnimalEstates implements EstateParticipant {
@Override public String id() { return "breedingbuddies-animals"; }
static boolean guard(JSONObject journal) {
try {
var stored = (JSONObject) journal.get("estate");
return Estates.decision(UUID.fromString((String) stored.get("death")))
.filter(d -> d.revision() == ((Number) stored.get("revision")).longValue() && d.toString().equals(stored.get("decision")) && AnimalOwnershipStore.ref(d.testator()).equals(stored.get("from"))
&& d.property().character().map(AnimalOwnershipStore::ref).equals(Optional.ofNullable(stored.get("to"))))
.filter(Estates::guardHolds).isPresent();
} catch (RuntimeException failure) { return false; }
}
@Override public Holdings describe(UUID character) {
AnimalOwnershipStore store = PluginData.ownership();
if (store == null) return Holdings.NONE;
List<String> labels = new ArrayList<>();
for (var entry : store.rows.entrySet()) {
try { if (AnimalOwnershipStore.owners(entry.getValue()).stream().anyMatch(ref -> ref.characterId().equals(character))) labels.add(entry.getKey().toString() + (store.held(entry.getKey()) ? " (held)" : "")); }
catch (RuntimeException failure) { labels.add(entry.getKey() + " (invalid ownership)"); }
}
return new Holdings(Map.of("animals", labels.size()), labels);
}
@Override public Outcome settle(EstateDecision decision) {
AnimalOwnershipStore store = PluginData.ownership();
if (store == null) return Outcome.retry("Animal store unavailable");
if (!Estates.guardHolds(decision)) return Outcome.retry("Estate guard changed");
try {
Set<String> moved = new TreeSet<>();
for (var path : store.files("ownership/journals")) {
JSONObject journal = AnimalFiles.object(Files.readString(path));
if (!(journal.get("estate") instanceof JSONObject previous)) continue;
if (!decision.deathId().toString().equals(previous.get("death"))) continue;
store.validateReceipt(path, journal);
if (!AnimalOwnershipStore.ref(decision.testator()).equals(previous.get("from"))) return Outcome.blocked("Estate testator conflicts with receipt");
if (((Number) previous.get("revision")).longValue() == decision.revision()
&& (!decision.toString().equals(previous.get("decision"))
|| !decision.property().character().map(AnimalOwnershipStore::ref).equals(Optional.ofNullable(previous.get("to"))))) return Outcome.blocked("Same-revision animal decision conflicts");
if (!Boolean.TRUE.equals(journal.get("done"))) {
if (!guard(journal)) return Outcome.blocked("Stale or incomplete animal estate fanout");
store.publish(path, journal); store.load(); store.ready = true; PluginData.rebuildAnimals();
}
for (Object id : (JSONArray) journal.get("animals")) moved.add((String) id);
}
if (!store.ready) return Outcome.blocked("Animal store requires migration/recovery");
for (var entry : store.rows.entrySet()) {
if (moved.contains(entry.getKey().toString()) && AnimalOwnershipStore.owners(entry.getValue()).contains(decision.testator())) return Outcome.blocked("Completed animal restored to testator " + entry.getKey());
}
if (decision.property().kind() != Recipient.Kind.CHARACTER) return done(moved);
CharacterRef target = decision.property().character().orElseThrow();
if (target.equals(decision.testator())) return done(moved);
boolean hasSource = false;
for (var entry : store.rows.entrySet()) {
Set<CharacterRef> owners = AnimalOwnershipStore.owners(entry.getValue());
if (!owners.contains(decision.testator())) continue;
if (store.held(entry.getKey())) return Outcome.blocked("Held animal " + entry.getKey());
if (owners.stream().anyMatch(owner -> owner.characterId().equals(target.characterId()) && !owner.equals(target))) return Outcome.blocked("Recipient account conflicts " + entry.getKey());
hasSource = true;
}
if (hasSource) {
PluginData.saveAnimals();
if (!Estates.guardHolds(decision)) return Outcome.retry("Estate guard changed after care flush");
}
Map<UUID, JSONObject> original = new LinkedHashMap<>(store.rows);
JSONArray current = new JSONArray(); JSONObject canonical = new JSONObject();
Map<UUID, JSONObject> planned = new LinkedHashMap<>();
for (var entry : store.rows.entrySet()) {
Set<CharacterRef> owners = AnimalOwnershipStore.owners(entry.getValue());
if (!owners.contains(decision.testator())) continue;
if (store.held(entry.getKey())) return Outcome.blocked("Held animal " + entry.getKey());
if (owners.stream().anyMatch(owner -> owner.characterId().equals(target.characterId()) && !owner.equals(target))) return Outcome.blocked("Recipient account conflicts " + entry.getKey());
FarmAnimal live = OwnershipManager.getAnimal(entry.getKey());
JSONObject row = live == null ? AnimalFiles.copy(entry.getValue()) : AnimalCodec.patch(entry.getValue(), live);
row.put("characterOwners", entry.getValue().get("characterOwners"));
JSONArray slots = new JSONArray();
for (Object value : (JSONArray) row.get("characterOwners")) if (!AnimalOwnershipStore.parseRef((JSONObject) value).equals(decision.testator())) slots.add(value);
if (!owners.contains(target)) slots.add(AnimalOwnershipStore.ref(target));
row.put("characterOwners", slots); planned.put(entry.getKey(), row); current.add(entry.getKey().toString()); canonical.put(entry.getKey().toString(), row);
}
if (!current.isEmpty()) {
JSONObject estate = new JSONObject(); estate.put("death", decision.deathId().toString()); estate.put("revision", decision.revision()); estate.put("decision", decision.toString());
estate.put("from", AnimalOwnershipStore.ref(decision.testator())); estate.put("to", AnimalOwnershipStore.ref(target));
JSONObject metadata = new JSONObject(); metadata.put("kind", "estate"); metadata.put("estate", estate); metadata.put("animals", current); metadata.put("canonical", canonical);
try { store.rows.putAll(planned); store.commit(store.fanout(), metadata); store.load(); PluginData.rebuildAnimals(); }
catch (IOException | RuntimeException failure) { store.rows.clear(); store.rows.putAll(original); throw failure; }
for (Object id : current) moved.add((String) id);
}
return done(moved);
} catch (Exception failure) {
store.ready = false;
return (failure instanceof IOException || failure.getCause() instanceof IOException) && store.blocked() == null ? Outcome.retry("Animal estate I/O: " + failure.getMessage()) : Outcome.blocked(store.blocked() == null ? "Invalid animal estate metadata: " + failure.getMessage() : store.blocked());
}
}
private static Outcome done(Set<String> moved) { return Outcome.done(Map.of("animals", moved.size()), List.copyOf(moved)); }
static void start(Plugin plugin) {
AnimalEstates participant = new AnimalEstates();
Bukkit.getServicesManager().register(EstateParticipant.class, participant, plugin, ServicePriority.Normal);
Bukkit.getScheduler().runTaskTimer(plugin, () -> {
for (EstateDecision decision : Estates.decided()) {
try { Estates.report(decision.deathId(), participant.id(), decision.revision(), participant.settle(decision)); }
catch (RuntimeException failure) { plugin.getLogger().warning("Animal estate " + decision.deathId() + " revision " + decision.revision() + ": " + failure.getMessage()); }
}
}, 3L, 20L * 60 * 60);
}
}
52 changes: 52 additions & 0 deletions src/main/java/net/tfminecraft/breedingbuddies/AnimalFiles.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package net.tfminecraft.breedingbuddies;

import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ContentHandler;
import org.json.simple.parser.ParseException;
import java.util.*;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import static java.nio.file.StandardOpenOption.*;

final class AnimalFiles {
private AnimalFiles() {}
static JSONObject object(String text) throws IOException {
try {
new JSONParser().parse(text, new ContentHandler() {
final Deque<Set<String>> keys = new ArrayDeque<>();
public void startJSON() {} public void endJSON() {}
public boolean startObject() { keys.push(new HashSet<>()); return true; }
public boolean endObject() { keys.pop(); return true; }
public boolean startObjectEntry(String key) throws ParseException {
if (!keys.peek().add(key)) throw new ParseException(ParseException.ERROR_UNEXPECTED_TOKEN, "Duplicate object key " + key);
return true;
}
public boolean endObjectEntry() { return true; }
public boolean startArray() { return true; } public boolean endArray() { return true; }
public boolean primitive(Object value) { return true; }
});
return (JSONObject) new JSONParser().parse(text);
}
catch (Exception failure) { throw new IOException("Invalid animal document", failure); }
}
static JSONObject copy(JSONObject value) { try { return object(value.toJSONString()); } catch (IOException impossible) { throw new IllegalStateException(impossible); } }
static String read(Path path) throws IOException { return Files.exists(path) ? Files.readString(path) : null; }
static void write(Path path, String text) throws IOException {
Files.createDirectories(path.getParent());
for (Path parent = path.toAbsolutePath().getParent(); parent != null; parent = parent.getParent()) {
try (FileChannel directory = FileChannel.open(parent, READ)) { directory.force(true); }
}
Path temporary = path.resolveSibling(path.getFileName() + ".next");
try (FileChannel file = FileChannel.open(temporary, CREATE, TRUNCATE_EXISTING, WRITE)) {
ByteBuffer bytes = StandardCharsets.UTF_8.encode(text);
while (bytes.hasRemaining()) file.write(bytes);
file.force(true);
}
Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
try (FileChannel directory = FileChannel.open(path.getParent(), READ)) { directory.force(true); }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package net.tfminecraft.breedingbuddies;
import net.tfminecraft.tlibs.character.Characters;
import java.io.IOException;
/** Linked only after the heir API capability probe. */
final class AnimalHeirs {
private AnimalHeirs() {}
static void open(AnimalOwnershipStore store) throws IOException { store.open(Characters.legacyHeirsReady(), Characters::legacyHeir); }
}
Loading
Loading