diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 0a3ebe3..f9e1571 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -18,6 +18,11 @@ jobs:
with:
persist-credentials: false
+ - name: Install shared plugin dependencies
+ uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74
+ with:
+ mode: pinned
+
- name: Set up JDK 21
uses: actions/setup-java@v6
with:
@@ -51,6 +56,11 @@ jobs:
PLUGIN_VERSION: ${{ steps.dev.outputs.version }}
run: mvn -B --no-transfer-progress -f integration-tests/pom.xml "-Dcompanionpets.version=$PLUGIN_VERSION" clean package
+ - name: Compile isolated ownership fixture
+ env:
+ PLUGIN_VERSION: ${{ steps.dev.outputs.version }}
+ run: mvn -B --no-transfer-progress -f integration-tests/ownership/pom.xml "-Dcompanionpets.version=$PLUGIN_VERSION" clean package
+
- name: Verify runtime JAR
env:
ARTIFACT_PATH: ${{ steps.dev.outputs.jar }}
diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml
index 8428d66..4c1349c 100644
--- a/.github/workflows/maven-release.yml
+++ b/.github/workflows/maven-release.yml
@@ -43,6 +43,11 @@ jobs:
}
[[ "${TAG^^}" != *SNAPSHOT* ]] || exit 1
+ - name: Install shared plugin dependencies
+ uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74
+ with:
+ mode: pinned
+
- name: Set version from tag and build
id: maven
env:
@@ -64,7 +69,8 @@ jobs:
run: |
python3 .github/scripts/plugin-artifact.py \
--jar "$ARTIFACT_PATH" --version "${TAG#v}" \
- --stage "$RUNNER_TEMP/plugin-release"
+ --stage "$RUNNER_TEMP/plugin-release" \
+ --dependencies .build/plugin-dependencies.json
- uses: actions/upload-artifact@v7
with:
diff --git a/README.md b/README.md
index fd7ff4c..b0a46c7 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,9 @@ CompanionPets gives players a companion that lives beyond a single entity. Each
pet hatches from an egg with its own name, sex, personality, and favourite toy,
and keeps its needs, bond, and learned tricks between summons. Vanilla wolves,
cats, and foxes need no model plugin; ModelEngine, MythicMobs, MMOItems, and
-ItemsAdder are optional.
+ItemsAdder are optional. TLibs 2.6.0 and a ready character identity provider
+(supplied by RPCharacters) are required for pet ownership actions. Pets and Pet
+Houses belong to their character; account-wide capacity limits remain shared.
## Features
@@ -24,7 +26,7 @@ ItemsAdder are optional.
- **Configurable species** — per-type bodies, eggs, interaction items, learnable
tricks, optional behaviours, and custom tricks, with optional ModelEngine models
and animations. Resting pets look around with bounded head movement.
-- **Staff tools** — browse, locate, and create pets for any player, give eggs,
+- **Staff tools** — browse, locate, and create pets for a living character, give eggs,
and audit every staff intervention.
- **Pet recovery** — keep pets and their learning across restarts, including pets
left outside their Pet House.
@@ -41,7 +43,7 @@ Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/
## Tests
-With Java 21 and Maven installed, run:
+With Java 21 and the pinned plugin dependencies installed, run:
```sh
mvn clean verify
diff --git a/integration-tests/ownership/LegacyApiCheck.java b/integration-tests/ownership/LegacyApiCheck.java
new file mode 100644
index 0000000..ba80ed2
--- /dev/null
+++ b/integration-tests/ownership/LegacyApiCheck.java
@@ -0,0 +1,23 @@
+import java.net.URLClassLoader;
+import java.nio.file.Path;
+
+/** Run with current Paper API dependencies on the parent classpath; load actual old TLibs separately. */
+public final class LegacyApiCheck {
+ public static void main(String[] args) throws Exception {
+ try (var loader = new URLClassLoader(new java.net.URL[]{Path.of(args[0]).toUri().toURL(),Path.of(args[1]).toUri().toURL()},LegacyApiCheck.class.getClassLoader()) {
+ @Override protected Class> loadClass(String name, boolean resolve) throws ClassNotFoundException {
+ if (name.startsWith("net.tfminecraft.companionpets.") || name.startsWith("net.tfminecraft.tlibs.")) {
+ Class> type=findLoadedClass(name);if(type==null)type=findClass(name);if(resolve)resolveClass(type);return type;
+ }
+ return super.loadClass(name,resolve);
+ }
+ }) {
+ Class> identity=loader.loadClass("net.tfminecraft.companionpets.ownership.CharacterAccess");
+ if(!Boolean.FALSE.equals(identity.getMethod("available").invoke(null)))throw new AssertionError("Old TLibs incorrectly accepts character ownership migration");
+ if(!Boolean.FALSE.equals(identity.getMethod("heirReady").invoke(null)))throw new AssertionError("Old provider readiness must fail closed");
+ Class> estates=loader.loadClass("net.tfminecraft.companionpets.estate.EstateProbe");
+ if(!Boolean.FALSE.equals(estates.getMethod("available").invoke(null)))throw new AssertionError("Old TLibs incorrectly accepts estate API");
+ System.out.println("{\"passed\":true,\"checks\":3,\"legacyJar\":\""+args[1]+"\"}");
+ }
+ }
+}
diff --git a/integration-tests/ownership/README.md b/integration-tests/ownership/README.md
new file mode 100644
index 0000000..786b376
--- /dev/null
+++ b/integration-tests/ownership/README.md
@@ -0,0 +1,20 @@
+# Loopback ownership fixture
+
+Build after the parent plugin's verified install:
+
+```sh
+tfmc-mvn -o -B -f integration-tests/ownership/pom.xml clean package
+```
+
+Use a fresh isolated Paper 1.21.10 directory, TLibs 2.6.0 and CompanionPets, loopback IP `127.0.0.1`, port `25583`, offline mode and a disposable world. This probe refuses other addresses/ports. Connect the synthetic offline account `S6PetProbe`; no real character files are required. The fixture registers a fake identity provider, creates its own pet/store and verifies actual native following under A, switches the same connected account to B, checks outgoing prompts/training, native-selector exclusion, swimming safety and body reattachment. It writes `plugins/CompanionPetsOwnershipProbe/result.json`, logs each check and shuts the isolated server down. Never install it on Main or Dev.
+
+With `native-combat: true`, the probe sets real native owner combat memories and verifies both owner-assist goals select A's target and reject same-account B; native self-defense remains registered. The probe also starts an actual training navigation goal under A and verifies it cannot remain active under B. `OWNERSHIP_PROOF PASS` and `result.json.passed=true` are both required; a successful Maven compilation alone is not runtime evidence.
+
+`LegacyApiCheck.java` loads CompanionPets classes and an actual older TLibs JAR in an isolated classloader, with the normal Paper API dependencies on the parent classpath:
+
+```sh
+javac -cp "$PAPER_API_CLASSPATH" -d /tmp/cp-legacy-check integration-tests/ownership/LegacyApiCheck.java
+java -cp "/tmp/cp-legacy-check:$PAPER_API_CLASSPATH" LegacyApiCheck target/classes /path/to/tlibs-2.3.0.jar
+```
+
+All three character/estate capability checks must report false without linkage failure. Use test dependencies as the API classpath; do not include an old TLibs JAR on the parent classpath.
diff --git a/integration-tests/ownership/pom.xml b/integration-tests/ownership/pom.xml
new file mode 100644
index 0000000..40ff80b
--- /dev/null
+++ b/integration-tests/ownership/pom.xml
@@ -0,0 +1,49 @@
+
+
+ 4.0.0
+ net.tfminecraft
+ companionpets-ownership-probe
+ 1.0.0
+
+ 21
+ UTF-8
+ main-SNAPSHOT
+
+
+
+ net.tfminecraft
+ companionpets
+ ${companionpets.version}
+ provided
+
+
+ me.plugins
+ tlibs
+ 2.6.0
+ provided
+
+
+ io.papermc.paper
+ paper-api
+ 1.21.10-R0.1-SNAPSHOT
+ provided
+
+
+
+
+ papermc
+ https://repo.papermc.io/repository/maven-public/
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.14.1
+
+
+
+
diff --git a/integration-tests/ownership/src/main/java/net/tfminecraft/companionpets/ownershipsmoke/OwnershipProbe.java b/integration-tests/ownership/src/main/java/net/tfminecraft/companionpets/ownershipsmoke/OwnershipProbe.java
new file mode 100644
index 0000000..68ce79a
--- /dev/null
+++ b/integration-tests/ownership/src/main/java/net/tfminecraft/companionpets/ownershipsmoke/OwnershipProbe.java
@@ -0,0 +1,145 @@
+package net.tfminecraft.companionpets.ownershipsmoke;
+
+import java.nio.file.Files;
+import java.util.*;
+import org.bukkit.*;
+import org.bukkit.entity.*;
+import org.bukkit.event.*;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.plugin.ServicePriority;
+import org.bukkit.plugin.java.JavaPlugin;
+import net.tfminecraft.companionpets.body.*;
+import net.tfminecraft.companionpets.config.CompanionConfig;
+import net.tfminecraft.companionpets.ownership.*;
+import net.tfminecraft.companionpets.pet.*;
+import net.tfminecraft.companionpets.runtime.*;
+import net.tfminecraft.companionpets.session.*;
+import net.tfminecraft.companionpets.store.PetStore;
+import net.tfminecraft.companionpets.visual.IdleVisual;
+import net.tfminecraft.tlibs.character.*;
+import com.destroystokyo.paper.entity.ai.*;
+
+/** Synthetic loopback-only fixture: no RPCharacters/player records or external services. */
+public final class OwnershipProbe extends JavaPlugin implements Listener, CharacterIdentity {
+ private final UUID a=UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), b=UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+ private CharacterSession session; private Player player; private Wolf wolf; private Zombie target; private Pet pet; private PetRuntime runtime; private PetStore store;
+ private PetActions actions; private Goal trainingGoal; private Location origin; private double aDistance; private final List checks=new ArrayList<>();
+ @Override public void onEnable() {
+ if (!"127.0.0.1".equals(Bukkit.getIp()) || Bukkit.getPort()!=25583) throw new IllegalStateException("Loopback-only ownership fixture requires port 25583");
+ Bukkit.getServicesManager().register(CharacterIdentity.class,this,this,ServicePriority.Highest);
+ Bukkit.getPluginManager().registerEvents(this,this);
+ }
+ @EventHandler public void joined(PlayerJoinEvent event) {
+ if (!event.getPlayer().getName().equals("S6PetProbe")) return;
+ player=event.getPlayer();session=new CharacterSession(new CharacterRef(player.getUniqueId(),a),1);
+ Bukkit.getScheduler().runTaskLater(this,()->phase(this::start),20);
+ }
+ private void phase(Runnable run) { try { run.run(); } catch(Throwable ex) { finish(false,ex.toString()); } }
+ private void check(boolean value,String text) { if(!value)throw new AssertionError(text);checks.add(text); }
+ private Goal follower() { return Bukkit.getMobGoals().getGoal(wolf,GoalKey.of(Tameable.class,new NamespacedKey(this,"owner_follow"))); }
+ private void start() {
+ try {
+ var world=player.getWorld();origin=new Location(world,0.5,130,0.5);
+ for(int x=-18;x<=35;x++)for(int z=-18;z<=18;z++){world.getBlockAt(x,129,z).setType(Material.STONE);world.getBlockAt(x,130,z).setType(Material.AIR);world.getBlockAt(x,131,z).setType(Material.AIR);}
+ world.setGameRule(GameRule.DO_MOB_SPAWNING,false);player.setGameMode(GameMode.SURVIVAL);player.teleport(origin.clone().add(10,0,0));
+ var config=new org.bukkit.configuration.file.YamlConfiguration();config.loadFromString("pets: {wolf: {entity: WOLF, egg: WOLF_SPAWN_EGG, native-combat: true}}\n");
+ store=new PetStore(this);check(store.load(),"synthetic store opened");var visual=new IdleVisual();var key=new NamespacedKey(this,"pet");
+ runtime=new PetRuntime(this,CompanionConfig.load(this,config),store,new Sessions(),new Bodies(this,key,visual),visual,key,new NamespacedKey(this,"toy"));actions=new PetActions(runtime);
+ Bukkit.getPluginManager().registerEvents(new CharacterLifecycle(runtime,actions),this);
+ pet=new Pet(UUID.randomUUID(),player.getUniqueId(),"wolf","Ownership fixture",PetSex.MALE);pet.ownership(player.getUniqueId(),a);
+ wolf=(Wolf)runtime.bodies().spawn(pet,runtime.config().type("wolf"),origin,player);
+ check(wolf!=null&&wolf.isTamed()&&wolf.getOwner()!=null&&wolf.getOwner().getUniqueId().equals(player.getUniqueId()),"production spawn tames to the routing account");
+ pet.entityId(wolf.getUniqueId());store.add(pet);
+ OwnerFollowGoal.install(this,wolf,pet);check(follower()!=null&&follower().shouldActivate(),"actual native follower activates for A");
+ aDistance=wolf.getLocation().distanceSquared(player.getLocation());
+ Bukkit.getScheduler().runTaskLater(this,()->phase(this::switched),35);
+ } catch(Exception ex){throw new RuntimeException(ex);}
+ }
+ private void switched() {
+ check(wolf.getLocation().distanceSquared(player.getLocation())phase(this::water),35);
+ }
+ private void proveOwnerAssists() {
+ try {
+ target=wolf.getWorld().spawn(origin.clone().add(2,0,2),Zombie.class);target.setAI(false);
+ markCombatMemory();
+ for(String label:List.of("owner_hurt_by","owner_hurt")) {
+ var assist=Bukkit.getMobGoals().getGoal(wolf,GoalKey.of(Tameable.class,new NamespacedKey(this,label)));
+ check(assist!=null&&assist.shouldActivate(),"actual "+label+" allows active A");
+ assist.start();check(wolf.getTarget()==target,"actual "+label+" delegates A's native target");assist.stop();wolf.setTarget(null);
+ }
+ check(Bukkit.getMobGoals().hasGoal(wolf,VanillaGoal.HURT_BY),"native self-defense target goal retained");
+ } catch(Exception ex){throw new RuntimeException(ex);}
+ }
+ private void markCombatMemory() throws Exception {
+ Object owner=player.getClass().getMethod("getHandle").invoke(player),victim=target.getClass().getMethod("getHandle").invoke(target);
+ for(String setter:List.of("setLastHurtByMob","setLastHurtMob")) {
+ var method=Arrays.stream(owner.getClass().getMethods()).filter(candidate->candidate.getName().equals(setter)&&candidate.getParameterCount()==1&&candidate.getParameterTypes()[0].isInstance(victim)).findFirst().orElseThrow();
+ method.invoke(owner,victim);
+ }
+ }
+ private void water() {
+ try {
+ markCombatMemory();
+ for(String label:List.of("owner_hurt_by","owner_hurt")) {
+ var assist=Bukkit.getMobGoals().getGoal(wolf,GoalKey.of(Tameable.class,new NamespacedKey(this,label)));
+ var field=assist.getClass().getDeclaredField("nativeGoal");field.setAccessible(true);
+ check(((Goal>)field.get(assist)).shouldActivate(),"ungated native "+label+" sees B's fresh account combat memory");
+ check(!assist.shouldActivate()&&!assist.shouldStayActive(),"character gate denies fresh B combat memory for "+label);
+ }
+ target.remove();
+ } catch(Exception ex){throw new RuntimeException(ex);}
+ check(Bukkit.getMobGoals().getRunningGoals(wolf).stream().noneMatch(goal->goal.getKey().equals(follower().getKey())),"actual selector excludes follower while B is active");
+ check(wolf.getLocation().distanceSquared(player.getLocation())>16,"A body remains separated from B");
+ var world=wolf.getWorld();for(int x=-2;x<=2;x++)for(int z=-2;z<=2;z++){world.getBlockAt(x,130,z).setType(Material.WATER);world.getBlockAt(x,131,z).setType(Material.WATER);}
+ wolf.teleport(origin);wolf.setAware(true);
+ Bukkit.getScheduler().runTaskLater(this,()->phase(this::wet),15);
+ }
+ private void wet() {
+ check(wolf.isInWater(),"actual body is in water");
+ check(Bukkit.getMobGoals().hasGoal(wolf,VanillaGoal.FLOAT),"native floating goal is retained");
+ check(!follower().shouldActivate()&&!follower().shouldStayActive(),"water/safety posture cannot bypass follower character gate");
+ runtime.bodies().reattach(wolf,pet,runtime.config().type("wolf"));
+ check(!follower().shouldActivate(),"reattach retains exact character gate");
+ check(wolf.getOwner()!=null&&wolf.getOwner().getUniqueId().equals(player.getUniqueId()),"production reattach retains vanilla routing account");
+ pet.ownershipHeld(true);check(!follower().shouldActivate(),"held ownership cannot activate follower");pet.ownershipHeld(false);
+ session=null;check(!follower().shouldActivate(),"unknown/inactive owner cannot activate follower");
+ finish(true,"native follow, switch, training cleanup, water and reattach proved");
+ }
+ private void finish(boolean passed,String message) {
+ try{getDataFolder().mkdirs();Files.writeString(getDataFolder().toPath().resolve("result.json"),"{\"passed\":"+passed+",\"checks\":"+checks.size()+",\"message\":\""+message.replace("\"","'")+"\"}\n");}
+ catch(Exception ex){getLogger().severe(ex.toString());}
+ checks.forEach(value->getLogger().info("CHECK "+value));getLogger().info("OWNERSHIP_PROOF "+(passed?"PASS ":"FAIL ")+message);
+ if(target!=null)target.remove();if(wolf!=null)wolf.remove();if(store!=null)store.close();Bukkit.getScheduler().runTaskLater(this,()->Bukkit.shutdown(),5);
+ }
+ @Override public Optional currentSession(Player target){return target==player?Optional.ofNullable(session):Optional.empty();}
+ @Override public boolean isCurrent(CharacterSession token){return token!=null&&token.equals(session);}
+ @Override public Optional info(UUID id){return player!=null&&(id.equals(a)||id.equals(b))?Optional.of(new CharacterInfo(new CharacterRef(player.getUniqueId(),id),id.equals(a)?"A":"B",CharacterStatus.ALIVE,null,Set.of(),0,null)):Optional.empty();}
+ @Override public Optional> characters(UUID account){return player!=null&&account.equals(player.getUniqueId())?Optional.of(List.of(info(a).orElseThrow(),info(b).orElseThrow())):Optional.empty();}
+ @Override public Optional lastPlayed(UUID account){return session==null?Optional.empty():info(session.ref().characterId());}
+ @Override public Optional legacyHeir(UUID account){return player!=null&&account.equals(player.getUniqueId())?Optional.of(new CharacterRef(account,a)):Optional.empty();}
+ @Override public boolean legacyHeirsReady(){return true;}
+ @Override public String displayName(Player target){return target.getName();}
+}
diff --git a/integration-tests/ownership/src/main/resources/plugin.yml b/integration-tests/ownership/src/main/resources/plugin.yml
new file mode 100644
index 0000000..18fe4a9
--- /dev/null
+++ b/integration-tests/ownership/src/main/resources/plugin.yml
@@ -0,0 +1,5 @@
+name: CompanionPetsOwnershipProbe
+main: net.tfminecraft.companionpets.ownershipsmoke.OwnershipProbe
+version: 1.0.0
+api-version: '1.21.10'
+depend: [CompanionPets, TLibs]
diff --git a/pom.xml b/pom.xml
index 49b5e9c..d209470 100644
--- a/pom.xml
+++ b/pom.xml
@@ -16,9 +16,16 @@
21
UTF-8
full
+ 2.6.0
+
+ me.plugins
+ tlibs
+ ${tlibs.version}
+ provided
+
io.papermc.paper
paper-api
diff --git a/src/main/java/net/tfminecraft/companionpets/PetsPlugin.java b/src/main/java/net/tfminecraft/companionpets/PetsPlugin.java
index fbd0c95..e7e60cf 100644
--- a/src/main/java/net/tfminecraft/companionpets/PetsPlugin.java
+++ b/src/main/java/net/tfminecraft/companionpets/PetsPlugin.java
@@ -48,6 +48,7 @@ public class PetsPlugin extends JavaPlugin {
private BukkitTask flusher;
private BukkitTask visualTicker;
private BukkitTask tailTicker;
+ private BukkitTask heirWait;
private PetVisual visual;
private PetRuntime runtime;
private PetListener petListener;
@@ -66,7 +67,7 @@ public void onEnable() {
return;
}
store = new PetStore(this);
- if (!store.load() || !store.beginSession()) {
+ if (!store.load() || (net.tfminecraft.companionpets.ownership.CharacterAccess.heirReady() && !store.migrate()) || !store.beginSession()) {
getServer().getPluginManager().disablePlugin(this);
return;
}
@@ -89,6 +90,10 @@ public void onEnable() {
Bodies bodies = new Bodies(this, petKey, visual);
runtime = new PetRuntime(this, config, store, new Sessions(), bodies, visual, petKey, toyKey);
actions = new PetActions(runtime);
+ if (net.tfminecraft.companionpets.ownership.CharacterAccess.available())
+ Bukkit.getPluginManager().registerEvents(new net.tfminecraft.companionpets.ownership.CharacterLifecycle(runtime, actions), this);
+ if (net.tfminecraft.companionpets.estate.EstateProbe.available())
+ net.tfminecraft.companionpets.estate.PetEstate.start(this, runtime, actions);
staff = new net.tfminecraft.companionpets.staff.StaffCommands(runtime, actions);
tests = new net.tfminecraft.companionpets.staff.TestCommands(runtime, actions, staff);
Bukkit.getPluginManager().registerEvents(staff.menus(), this);
@@ -108,6 +113,18 @@ public void onEnable() {
actions.reattach(entity);
}
}
+ if (!net.tfminecraft.companionpets.ownership.CharacterAccess.heirReady()) {
+ heirWait = Bukkit.getScheduler().runTaskTimer(this, () -> {
+ if (!net.tfminecraft.companionpets.ownership.CharacterAccess.heirReady()) return;
+ actions.clearInteractions(); runtime.sessions().clearForReload();
+ if (!store.save() || !store.migrate()) { getServer().getPluginManager().disablePlugin(this); return; }
+ for (var pet : store.all()) {
+ Entity body = runtime.entity(pet); var type = runtime.config().type(pet.typeId());
+ if (body != null && type != null) runtime.bodies().reattach(body, pet, type);
+ }
+ heirWait.cancel();
+ }, 1L, 20L);
+ }
getLogger().info("CompanionPets enabled (" + config.types().size() + " pet types)");
}
@@ -120,6 +137,7 @@ public void onDisable() {
if (body instanceof org.bukkit.entity.Mob mob) releaseGoals(mob);
}
}
+ if (heirWait != null) heirWait.cancel();
if (visualTicker != null) visualTicker.cancel();
if (tailTicker != null) tailTicker.cancel();
if (ticker != null) {
diff --git a/src/main/java/net/tfminecraft/companionpets/body/Bodies.java b/src/main/java/net/tfminecraft/companionpets/body/Bodies.java
index 30df904..566c6c3 100644
--- a/src/main/java/net/tfminecraft/companionpets/body/Bodies.java
+++ b/src/main/java/net/tfminecraft/companionpets/body/Bodies.java
@@ -102,13 +102,19 @@ public void reattach(Entity entity, Pet pet, PetTypeDef type) {
configure(entity, type);
tag(entity, pet.id());
name(entity, pet.name());
+ bindOwner(entity, pet);
+ visual.apply(entity, type);
+ keepPersistent(entity);
+ if (entity instanceof Mob mob) mob.setAware(aware(pet) || net.tfminecraft.companionpets.behavior.WaterEscape.needed(mob));
+ }
+
+ /** Engine routing and the character gate must survive even when the species config is unavailable. */
+ public void bindOwner(Entity entity, Pet pet) {
if (entity instanceof Tameable tameable) {
tameable.setTamed(true);
tameable.setOwner(Bukkit.getOfflinePlayer(pet.ownerId()));
+ OwnerFollowGoal.install(plugin, tameable, pet);
}
- visual.apply(entity, type);
- keepPersistent(entity);
- if (entity instanceof Mob mob) mob.setAware(aware(pet) || net.tfminecraft.companionpets.behavior.WaterEscape.needed(mob));
}
public UUID readId(Entity entity) {
@@ -130,10 +136,7 @@ private void prepare(Entity entity, Pet pet, PetTypeDef type, Player owner) {
if (entity instanceof Ageable ageable) {
ageable.setAdult();
}
- if (entity instanceof Tameable tameable) {
- tameable.setTamed(true);
- tameable.setOwner(owner != null ? owner : Bukkit.getOfflinePlayer(pet.ownerId()));
- }
+ bindOwner(entity, pet);
visual.apply(entity, type);
keepPersistent(entity);
if (entity instanceof Mob mob) mob.setAware(aware(pet) || net.tfminecraft.companionpets.behavior.WaterEscape.needed(mob));
diff --git a/src/main/java/net/tfminecraft/companionpets/body/OwnerFollowGoal.java b/src/main/java/net/tfminecraft/companionpets/body/OwnerFollowGoal.java
new file mode 100644
index 0000000..4e2fe14
--- /dev/null
+++ b/src/main/java/net/tfminecraft/companionpets/body/OwnerFollowGoal.java
@@ -0,0 +1,56 @@
+package net.tfminecraft.companionpets.body;
+
+import java.util.EnumSet;
+import org.bukkit.Bukkit;
+import org.bukkit.NamespacedKey;
+import org.bukkit.entity.Tameable;
+import org.bukkit.plugin.Plugin;
+import com.destroystokyo.paper.entity.ai.Goal;
+import com.destroystokyo.paper.entity.ai.GoalKey;
+import com.destroystokyo.paper.entity.ai.GoalType;
+import com.destroystokyo.paper.entity.ai.VanillaGoal;
+import net.tfminecraft.companionpets.ownership.CharacterAccess;
+import net.tfminecraft.companionpets.ownership.OwnerSession;
+import net.tfminecraft.companionpets.pet.Pet;
+
+/** Gates native owner-follow and owner-assist goals; swimming and self-defense remain independent. */
+public final class OwnerFollowGoal implements Goal {
+ private final GoalKey key;
+ private final Goal nativeGoal;
+ private Pet pet;
+ private OwnerSession activeSession;
+ private OwnerFollowGoal(GoalKey key, Goal nativeGoal, Pet pet) {
+ this.key = key; this.nativeGoal = nativeGoal; this.pet = pet;
+ }
+ public static void install(Plugin plugin, Tameable body, Pet pet) {
+ wrap(plugin, body, pet, VanillaGoal.FOLLOW_OWNER, "owner_follow", 6);
+ wrap(plugin, body, pet, VanillaGoal.OWNER_HURT_BY, "owner_hurt_by", 1);
+ wrap(plugin, body, pet, VanillaGoal.OWNER_HURT, "owner_hurt", 2);
+ }
+ private static void wrap(Plugin plugin, Tameable body, Pet pet, GoalKey nativeKey, String label, int priority) {
+ var goals = Bukkit.getMobGoals();
+ GoalKey key = GoalKey.of(Tameable.class, new NamespacedKey(plugin, label));
+ Goal previous = goals.getGoal(body, key);
+ if (previous instanceof OwnerFollowGoal wrapped) { wrapped.pet = pet; return; }
+ Goal nativeGoal = goals.getGoal(body, nativeKey);
+ if (nativeGoal == null) return;
+ goals.removeGoal(body, nativeGoal);
+ goals.addGoal(body, priority, new OwnerFollowGoal(key, nativeGoal, pet));
+ }
+ private boolean allowed() { return !pet.stored() && !pet.dead() && CharacterAccess.owns(pet, Bukkit.getPlayer(pet.ownerId())); }
+ @Override public boolean shouldActivate() { return allowed() && nativeGoal.shouldActivate(); }
+ private boolean activeAllowed() {
+ return allowed() && (activeSession == null || activeSession.account().equals(pet.ownerId())
+ && activeSession.character().equals(pet.ownerCharacterId()) && CharacterAccess.current(activeSession));
+ }
+ @Override public boolean shouldStayActive() { return activeAllowed() && nativeGoal.shouldStayActive(); }
+ @Override public void start() {
+ if (!allowed()) return;
+ activeSession = CharacterAccess.capture(Bukkit.getPlayer(pet.ownerId()));
+ if (activeSession != null) nativeGoal.start();
+ }
+ @Override public void stop() { nativeGoal.stop(); activeSession = null; }
+ @Override public void tick() { if (activeAllowed()) nativeGoal.tick(); else nativeGoal.stop(); }
+ @Override public GoalKey getKey() { return key; }
+ @Override public EnumSet getTypes() { return nativeGoal.getTypes(); }
+}
diff --git a/src/main/java/net/tfminecraft/companionpets/estate/EstateProbe.java b/src/main/java/net/tfminecraft/companionpets/estate/EstateProbe.java
new file mode 100644
index 0000000..6190aac
--- /dev/null
+++ b/src/main/java/net/tfminecraft/companionpets/estate/EstateProbe.java
@@ -0,0 +1,12 @@
+package net.tfminecraft.companionpets.estate;
+
+public final class EstateProbe {
+ private EstateProbe() { }
+ public static boolean available() {
+ try {
+ Class.forName("net.tfminecraft.tlibs.character.estate.Estates");
+ Class.forName("net.tfminecraft.tlibs.character.estate.EstateParticipant");
+ return true;
+ } catch (ReflectiveOperationException | LinkageError ex) { return false; }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/companionpets/estate/PetEstate.java b/src/main/java/net/tfminecraft/companionpets/estate/PetEstate.java
new file mode 100644
index 0000000..d6a7b1e
--- /dev/null
+++ b/src/main/java/net/tfminecraft/companionpets/estate/PetEstate.java
@@ -0,0 +1,298 @@
+package net.tfminecraft.companionpets.estate;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import org.bukkit.Bukkit;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.plugin.ServicePriority;
+import net.tfminecraft.companionpets.ownership.DurableFiles;
+import net.tfminecraft.companionpets.ownership.OwnershipMigration;
+import net.tfminecraft.companionpets.pet.Pet;
+import net.tfminecraft.companionpets.runtime.PetActions;
+import net.tfminecraft.companionpets.runtime.PetRuntime;
+import net.tfminecraft.tlibs.character.CharacterRef;
+import net.tfminecraft.tlibs.character.estate.*;
+
+/** Transfers only owner envelopes; the pet and all of its learning and friendships survive. */
+public final class PetEstate implements EstateParticipant {
+ private final PetRuntime runtime;
+ private final PetActions actions;
+ private final Path journals;
+ Predicate guard = Estates::guardHolds;
+ Supplier> decided = Estates::decided;
+
+ public PetEstate(PetRuntime runtime, PetActions actions, Path journals) {
+ this.runtime = runtime; this.actions = actions; this.journals = journals;
+ }
+ public static void start(Plugin plugin, PetRuntime runtime, PetActions actions) {
+ PetEstate estate = new PetEstate(runtime, actions, plugin.getDataFolder().toPath().resolve("estates"));
+ estate.recover();
+ Bukkit.getServicesManager().register(EstateParticipant.class, estate, plugin, ServicePriority.Normal);
+ Bukkit.getScheduler().runTaskTimer(plugin, estate::reconcile, 1L, 72000L);
+ }
+ @Override public String id() { return "companionpets"; }
+ @Override public Holdings describe(UUID character) {
+ List labels = new ArrayList<>();
+ runtime.store().ofCharacter(character).forEach(pet -> labels.add("Pet: " + pet.name()));
+ runtime.store().kennelCharacters().forEach((key, owner) -> { if (character.equals(owner)) labels.add("Pet House: " + key); });
+ return labels.isEmpty() ? Holdings.NONE : new Holdings(Map.of("pets-and-houses", labels.size()), labels);
+ }
+ @Override public Outcome settle(EstateDecision decision) {
+ if (runtime.store().persistenceConflict()) return Outcome.blocked("Pet source changed outside its current writer; review is required");
+ if (!net.tfminecraft.companionpets.ownership.CharacterAccess.heirReady() || !runtime.store().ownershipReady())
+ return Outcome.retry("Reviewed pet ownership and persistence are not ready");
+ if (!guard.test(decision)) return Outcome.retry("The exact estate decision is not current");
+ if (decision.property().character().isPresent() && runtime.store().hasUnassignedOwner(decision.testator().ownerId()))
+ return Outcome.blocked("Legacy pet/house ownership for this account still needs its reviewed heir");
+ try {
+ Files.createDirectories(journals);
+ List moved = new ArrayList<>();
+ Path file = journals.resolve(decision.deathId() + "-" + decision.revision() + ".yml");
+ Map history = new LinkedHashMap<>();
+ Set otherPets = new java.util.HashSet<>(), otherKennels = new java.util.HashSet<>();
+ try (var files = Files.list(journals)) {
+ for (Path older : files.filter(path -> path.toString().endsWith(".yml")).toList()) {
+ YamlConfiguration receipt = readPlan(older);
+ history.put(older, receipt);
+ if (!decision.deathId().toString().equals(receipt.getString("death")) && !receipt.getBoolean("complete")) {
+ hold(receipt);
+ otherPets.addAll(receipt.getStringList("pets"));
+ otherKennels.addAll(receipt.getStringList("kennels"));
+ }
+ }
+ }
+ YamlConfiguration current = history.get(file);
+ List candidatePets = current == null ? runtime.store().ofCharacter(decision.testator().characterId()).stream()
+ .filter(pet -> pet.ownerId().equals(decision.testator().ownerId())).map(pet -> pet.id().toString()).toList()
+ : current.getStringList("pets");
+ List candidateKennels = current == null ? runtime.store().kennelCharacters().entrySet().stream()
+ .filter(entry -> decision.testator().characterId().equals(entry.getValue())).map(Map.Entry::getKey).toList()
+ : current.getStringList("kennels");
+ if ((current == null || !current.getBoolean("complete")) && decision.property().character().isPresent()
+ && (!java.util.Collections.disjoint(candidatePets, otherPets)
+ || !java.util.Collections.disjoint(candidateKennels, otherKennels)))
+ return Outcome.blocked("Another death's incomplete pet estate intent needs review before these holdings can move");
+ for (var historical : history.entrySet()) {
+ Path older = historical.getKey();
+ YamlConfiguration receipt = historical.getValue();
+ if (!decision.deathId().toString().equals(receipt.getString("death"))) continue;
+ if (!decision.testator().ownerId().toString().equals(receipt.getString("from-account"))
+ || !decision.testator().characterId().toString().equals(receipt.getString("from-character")))
+ throw new InvalidIntent("Historical receipt testator conflicts with this death: " + older.getFileName());
+ if (receipt.getBoolean("complete")) moved.addAll(receipt.getStringList("moved"));
+ else if (!older.equals(file)) {
+ if (changed(receipt)) { hold(receipt); return Outcome.blocked("An earlier partial estate intent needs review: " + older.getFileName()); }
+ receipt.set("complete", true); receipt.set("obsolete", true);
+ DurableFiles.write(older, receipt.saveToString());
+ }
+ }
+ if (decision.property().character().isEmpty()) return done(moved);
+ if (completedConflict(decision.testator(), moved))
+ return Outcome.blocked("A completed holding appears testator-owned again; review the source and receipts");
+ YamlConfiguration plan;
+ if (Files.exists(file)) {
+ plan = readPlan(file);
+ if (!matchesDecision(plan, decision)) {
+ hold(plan); return Outcome.blocked("Estate intent does not match the exact recipient/decision envelope");
+ }
+ if (plan.getBoolean("complete")) {
+ if (remaining(decision.testator())) return Outcome.blocked("Completed estate receipt conflicts with remaining testator property; review a new revision");
+ return done(moved);
+ }
+ } else {
+ CharacterRef to = decision.property().character().orElseThrow();
+ plan = new YamlConfiguration();
+ plan.set("death", decision.deathId().toString()); plan.set("revision", decision.revision());
+ plan.set("decision", decision.toString()); plan.set("complete", false);
+ plan.set("from-account", decision.testator().ownerId().toString());
+ plan.set("from-character", decision.testator().characterId().toString());
+ plan.set("to-account", to.ownerId().toString()); plan.set("to-character", to.characterId().toString());
+ plan.set("pets", candidatePets);
+ plan.set("kennels", candidateKennels);
+ DurableFiles.write(file, plan.saveToString());
+ }
+ if (!guard.test(decision)) return Outcome.retry("Estate changed before publication");
+ UUID fromAccount = UUID.fromString(plan.getString("from-account"));
+ UUID fromCharacter = UUID.fromString(plan.getString("from-character"));
+ UUID toAccount = UUID.fromString(plan.getString("to-account"));
+ UUID toCharacter = UUID.fromString(plan.getString("to-character"));
+ Map before = new LinkedHashMap<>();
+ Map housesBefore = new LinkedHashMap<>();
+ List thisMove = new ArrayList<>();
+ for (String id : plan.getStringList("pets")) {
+ Pet pet = runtime.store().get(UUID.fromString(id));
+ if (pet == null || !(matches(pet, fromAccount, fromCharacter) || matches(pet, toAccount, toCharacter))) {
+ hold(plan); return Outcome.blocked("Pet changed or disappeared after estate planning: " + id);
+ }
+ before.put(pet, new CharacterRef(pet.ownerId(), pet.ownerCharacterId()));
+ thisMove.add("pet:" + id);
+ }
+ for (String key : plan.getStringList("kennels")) {
+ UUID account = runtime.store().kennelOwner(key), character = runtime.store().kennelCharacter(key);
+ if (!((fromAccount.equals(account) && fromCharacter.equals(character)) || (toAccount.equals(account) && toCharacter.equals(character)))) {
+ hold(plan); return Outcome.blocked("Pet House changed after estate planning: " + key);
+ }
+ housesBefore.put(key, new CharacterRef(account, character)); thisMove.add("kennel:" + key);
+ }
+ before.keySet().forEach(pet -> {
+ pet.ownershipHeld(true); actions.clearInteractions(pet);
+ actions.releaseFetch(pet, null, false); runtime.sessions().clearPet(pet.id());
+ pet.ownership(toAccount, toCharacter);
+ });
+ housesBefore.keySet().forEach(key -> { runtime.store().holdKennel(key, true); runtime.store().kennel(key, toAccount, toCharacter); });
+ if (!guard.test(decision) || !runtime.store().saveOwnership()) {
+ before.forEach((pet, owner) -> { pet.ownership(owner.ownerId(), owner.characterId()); pet.ownershipHeld(true); });
+ housesBefore.forEach((key, owner) -> { runtime.store().kennel(key, owner.ownerId(), owner.characterId()); runtime.store().holdKennel(key, true); });
+ return runtime.store().persistenceConflict() ? Outcome.blocked("Pet save changed outside the writer; preserve the intent and current file for review")
+ : Outcome.retry("Pet ownership could not be durably published");
+ }
+ plan.set("moved", thisMove); plan.set("complete", true);
+ DurableFiles.write(file, plan.saveToString());
+ before.keySet().forEach(pet -> {
+ pet.ownershipHeld(false);
+ var body = runtime.entity(pet); var type = runtime.config().type(pet.typeId());
+ if (body != null) {
+ runtime.bodies().bindOwner(body, pet);
+ if (type != null) runtime.bodies().reattach(body, pet, type);
+ }
+ });
+ housesBefore.keySet().forEach(key -> runtime.store().holdKennel(key, false));
+ moved.addAll(thisMove);
+ return done(moved);
+ } catch (InvalidIntent | RuntimeException ex) {
+ holdAll();
+ runtime.plugin().getLogger().severe("Pet estate intent needs review: " + ex.getMessage());
+ return Outcome.blocked("Pet estate intent needs review: " + ex.getMessage());
+ } catch (IOException ex) {
+ runtime.plugin().getLogger().warning("Pet estate held for retry: " + ex.getMessage());
+ return Outcome.retry("Pet estate persistence failed: " + ex.getMessage());
+ }
+ }
+ private static final class InvalidIntent extends IOException {
+ InvalidIntent(String message) { super(message); }
+ }
+ private static YamlConfiguration readPlan(Path file) throws IOException {
+ YamlConfiguration plan;
+ try { plan = OwnershipMigration.read(file); }
+ catch (IOException ex) {
+ if (ex.getCause() instanceof org.bukkit.configuration.InvalidConfigurationException)
+ throw new InvalidIntent("Unreadable journal: " + file.getFileName());
+ throw ex;
+ }
+ for (String key : List.of("death", "from-account", "from-character", "to-account", "to-character")) {
+ if (OwnershipMigration.uuid(plan.getString(key)) == null)
+ throw new InvalidIntent("Invalid " + key + " in " + file.getFileName());
+ }
+ if (!(plan.get("revision") instanceof Integer || plan.get("revision") instanceof Long) || plan.getLong("revision") < 0
+ || !(plan.get("complete") instanceof Boolean) || plan.getString("decision", "").isBlank())
+ throw new InvalidIntent("Invalid decision envelope in " + file.getFileName());
+ CharacterRef source = new CharacterRef(OwnershipMigration.uuid(plan.getString("from-account")), OwnershipMigration.uuid(plan.getString("from-character")));
+ CharacterRef recipient = new CharacterRef(OwnershipMigration.uuid(plan.getString("to-account")), OwnershipMigration.uuid(plan.getString("to-character")));
+ String captured = plan.getString("decision");
+ if (!captured.startsWith("EstateDecision[deathId=" + plan.getString("death") + ", testator=" + source + ", diedAt=")
+ || !captured.contains(", property=" + Recipient.character(recipient) + ", guildLocks=")
+ || !captured.endsWith(", revision=" + plan.getLong("revision") + "]"))
+ throw new InvalidIntent("Journal envelope conflicts with its captured decision: " + file.getFileName());
+ if (!file.getFileName().toString().equals(plan.getString("death") + "-" + plan.getLong("revision") + ".yml"))
+ throw new InvalidIntent("Journal path does not match its death/revision: " + file.getFileName());
+ for (String key : List.of("pets", "kennels")) {
+ if (!(plan.get(key) instanceof List> values) || values.stream().anyMatch(value -> !(value instanceof String))
+ || values.stream().distinct().count() != values.size())
+ throw new InvalidIntent("Invalid " + key + " list in " + file.getFileName());
+ }
+ if (plan.getStringList("pets").stream().anyMatch(id -> OwnershipMigration.uuid(id) == null))
+ throw new InvalidIntent("Invalid pet ID in " + file.getFileName());
+ if (plan.getBoolean("complete")) {
+ Object raw = plan.get("moved");
+ List moved = plan.getStringList("moved");
+ List expected = new ArrayList<>();
+ plan.getStringList("pets").forEach(id -> expected.add("pet:" + id));
+ plan.getStringList("kennels").forEach(key -> expected.add("kennel:" + key));
+ if ((raw != null && (!(raw instanceof List> values) || values.stream().anyMatch(value -> !(value instanceof String))))
+ || moved.stream().distinct().count() != moved.size()
+ || (plan.getBoolean("obsolete") ? !moved.isEmpty() : raw == null || !Set.copyOf(moved).equals(Set.copyOf(expected))))
+ throw new InvalidIntent("Completed receipt IDs conflict with its planned holdings: " + file.getFileName());
+ }
+ return plan;
+ }
+ private boolean completedConflict(CharacterRef owner, List moved) {
+ boolean conflict = false;
+ for (Pet pet : runtime.store().ofCharacter(owner.characterId())) {
+ if (owner.ownerId().equals(pet.ownerId()) && moved.contains("pet:" + pet.id())) {
+ pet.ownershipHeld(true); conflict = true;
+ }
+ }
+ for (var entry : runtime.store().kennelCharacters().entrySet()) {
+ if (owner.characterId().equals(entry.getValue()) && owner.ownerId().equals(runtime.store().kennelOwner(entry.getKey()))
+ && moved.contains("kennel:" + entry.getKey())) {
+ runtime.store().holdKennel(entry.getKey(), true); conflict = true;
+ }
+ }
+ return conflict;
+ }
+ private boolean remaining(CharacterRef owner) {
+ return runtime.store().ofCharacter(owner.characterId()).stream().anyMatch(pet -> owner.ownerId().equals(pet.ownerId()))
+ || runtime.store().kennelCharacters().entrySet().stream().anyMatch(entry -> owner.characterId().equals(entry.getValue())
+ && owner.ownerId().equals(runtime.store().kennelOwner(entry.getKey())));
+ }
+ private static boolean matchesDecision(YamlConfiguration plan, EstateDecision decision) {
+ CharacterRef recipient = decision.property().character().orElseThrow();
+ return decision.toString().equals(plan.getString("decision"))
+ && decision.deathId().toString().equals(plan.getString("death"))
+ && decision.revision() == plan.getLong("revision")
+ && decision.testator().ownerId().toString().equals(plan.getString("from-account"))
+ && decision.testator().characterId().toString().equals(plan.getString("from-character"))
+ && recipient.ownerId().toString().equals(plan.getString("to-account"))
+ && recipient.characterId().toString().equals(plan.getString("to-character"));
+ }
+ private static boolean matches(Pet pet, UUID account, UUID character) {
+ return account.equals(pet.ownerId()) && character.equals(pet.ownerCharacterId());
+ }
+ private boolean changed(YamlConfiguration plan) {
+ UUID account = OwnershipMigration.uuid(plan.getString("from-account"));
+ UUID character = OwnershipMigration.uuid(plan.getString("from-character"));
+ return plan.getStringList("pets").stream().anyMatch(id -> {
+ Pet pet = runtime.store().get(OwnershipMigration.uuid(id));
+ return pet == null || !matches(pet, account, character);
+ }) || plan.getStringList("kennels").stream().anyMatch(key -> !(account.equals(runtime.store().kennelOwner(key)) && character.equals(runtime.store().kennelCharacter(key))));
+ }
+ private void hold(YamlConfiguration plan) {
+ plan.getStringList("kennels").forEach(key -> runtime.store().holdKennel(key, true));
+ plan.getStringList("pets").forEach(id -> { Pet pet = runtime.store().get(OwnershipMigration.uuid(id)); if (pet != null) pet.ownershipHeld(true); });
+ }
+ private void holdAll() {
+ runtime.store().all().forEach(pet -> pet.ownershipHeld(true));
+ runtime.store().kennelCharacters().keySet().forEach(key -> runtime.store().holdKennel(key, true));
+ }
+ private static Outcome done(List moved) {
+ List ids = moved.stream().distinct().sorted().toList();
+ return Outcome.done(Map.of("pets-and-houses", ids.size()), ids);
+ }
+ private void recover() {
+ if (!Files.isDirectory(journals)) return;
+ try (var files = Files.list(journals)) {
+ for (Path file : files.filter(path -> path.toString().endsWith(".yml")).toList()) {
+ YamlConfiguration plan = readPlan(file);
+ if (!plan.getBoolean("complete")) hold(plan);
+ }
+ } catch (IOException | RuntimeException ex) {
+ holdAll();
+ runtime.plugin().getLogger().severe("Pet estate journals unreadable; ownership stays held: " + ex.getMessage());
+ }
+ }
+ void reconcile() {
+ for (EstateDecision decision : decided.get()) {
+ if (guard.test(decision)) Estates.report(decision.deathId(), id(), decision.revision(), settle(decision));
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/companionpets/gui/MenuHolder.java b/src/main/java/net/tfminecraft/companionpets/gui/MenuHolder.java
index b541dff..3e885d5 100644
--- a/src/main/java/net/tfminecraft/companionpets/gui/MenuHolder.java
+++ b/src/main/java/net/tfminecraft/companionpets/gui/MenuHolder.java
@@ -14,6 +14,10 @@ public enum Kind {
}
private final Kind kind;
+ private net.tfminecraft.companionpets.ownership.OwnerSession session;
+ public void capture(org.bukkit.entity.Player player) { session = net.tfminecraft.companionpets.ownership.CharacterAccess.capture(player); }
+ public boolean current(org.bukkit.entity.Player player) { return session != null && session.account().equals(player.getUniqueId())
+ && net.tfminecraft.companionpets.ownership.CharacterAccess.current(session); }
private final UUID petId;
private final String word;
private Inventory inventory;
diff --git a/src/main/java/net/tfminecraft/companionpets/gui/PetMenus.java b/src/main/java/net/tfminecraft/companionpets/gui/PetMenus.java
index df397f5..e3621a6 100644
--- a/src/main/java/net/tfminecraft/companionpets/gui/PetMenus.java
+++ b/src/main/java/net/tfminecraft/companionpets/gui/PetMenus.java
@@ -66,6 +66,7 @@ public void openCare(org.bukkit.entity.Player player, Pet pet, boolean petHouseB
}
public void openCare(org.bukkit.entity.Player player, Pet pet, boolean petHouseBack, int petHousePage, org.bukkit.Location house) {
MenuHolder holder = new MenuHolder(MenuHolder.Kind.CARE, pet.id(), null);
+ holder.capture(player);
holder.house(house);
holder.petHouseBack(petHouseBack);
holder.petHousePage(petHousePage);
@@ -85,7 +86,7 @@ public void refreshCare(org.bukkit.entity.Player player, Pet pet) {
}
private void fillCare(org.bukkit.entity.Player player, Inventory inventory, Pet pet) {
- fillProfile(inventory, pet, pet.ownerId().equals(player.getUniqueId()),
+ fillProfile(inventory, pet, runtime.owns(pet, player),
inventory.getHolder() instanceof MenuHolder holder && holder.petHouseBack());
}
@@ -228,12 +229,13 @@ public void openKennel(org.bukkit.entity.Player player, int requestedPage) {
}
public void openKennel(org.bukkit.entity.Player player, int requestedPage, org.bukkit.Location house) {
MenuHolder holder = new MenuHolder(MenuHolder.Kind.KENNEL, null, null);
+ holder.capture(player);
holder.house(house);
Inventory inventory = Bukkit.createInventory(holder, 54, title("Pet House"));
holder.inventory(inventory);
frame(inventory);
int slot = 0;
- List pets = runtime.store().of(player.getUniqueId()).stream()
+ List pets = runtime.owned(player).stream()
.sorted(java.util.Comparator.comparing((Pet p) -> p.name(), String.CASE_INSENSITIVE_ORDER).thenComparing(Pet::id)).toList();
int page = Math.max(0, Math.min(requestedPage, Math.max(0, (pets.size() - 1) / 45)));
holder.page(page);
@@ -263,6 +265,7 @@ public void openLearned(org.bukkit.entity.Player player, Pet pet) {
public void openLearned(org.bukkit.entity.Player player, Pet pet, int page) {
MenuHolder holder = new MenuHolder(MenuHolder.Kind.LEARNED, pet.id(), null);
+ holder.capture(player);
inheritPetHouseNavigation(player, holder);
Inventory inventory = Bukkit.createInventory(holder, 27, title(pet.name() + "'s tricks"));
holder.inventory(inventory);
@@ -315,6 +318,7 @@ public void openTricks(org.bukkit.entity.Player player, Pet pet, String word) {
public void openTricks(org.bukkit.entity.Player player, Pet pet, String word, int page) {
MenuHolder holder = new MenuHolder(MenuHolder.Kind.TRICK, pet.id(), word);
+ holder.capture(player);
inheritPetHouseNavigation(player, holder);
Inventory inventory = Bukkit.createInventory(holder, 27, title("Choose a trick"));
holder.inventory(inventory);
diff --git a/src/main/java/net/tfminecraft/companionpets/listen/FurniturePetHouses.java b/src/main/java/net/tfminecraft/companionpets/listen/FurniturePetHouses.java
index 0f7574f..368b67a 100644
--- a/src/main/java/net/tfminecraft/companionpets/listen/FurniturePetHouses.java
+++ b/src/main/java/net/tfminecraft/companionpets/listen/FurniturePetHouses.java
@@ -65,8 +65,8 @@ private void register(ClassLoader loader, String name, EventPriority priority, E
void placed(Event event) {
Entity entity = petHouse(event);
- if (entity == null || !(event instanceof PlayerEvent placement) || placement.getPlayer() == null) return;
- runtime.store().kennel(key(entity), placement.getPlayer().getUniqueId());
+ if (entity == null || !(event instanceof PlayerEvent placement) || placement.getPlayer() == null || runtime.characterId(placement.getPlayer()) == null) return;
+ runtime.store().kennel(key(entity), placement.getPlayer().getUniqueId(), runtime.characterId(placement.getPlayer()));
runtime.store().requestSave();
PetFx.bar(placement.getPlayer(), "Pet House placed. Right-click it to look after your pets");
}
@@ -84,7 +84,7 @@ void interact(Event event) {
UUID owner = runtime.store().kennelOwner(key(entity));
if (owner == null) return;
cancellable.setCancelled(true);
- if (!owner.equals(interaction.getPlayer().getUniqueId())) {
+ if (!runtime.ownsKennel(key(entity), interaction.getPlayer())) {
PetFx.bar(interaction.getPlayer(), "This Pet House belongs to someone else");
return;
}
diff --git a/src/main/java/net/tfminecraft/companionpets/listen/PetListener.java b/src/main/java/net/tfminecraft/companionpets/listen/PetListener.java
index f772f55..da7571b 100644
--- a/src/main/java/net/tfminecraft/companionpets/listen/PetListener.java
+++ b/src/main/java/net/tfminecraft/companionpets/listen/PetListener.java
@@ -65,7 +65,7 @@ public void onTeleport(EntityTeleportEvent event) {
if (actions.fetchingOrReturning(pet) || actions.greeting(pet) || actions.socializing(pet) || pet.activity() == net.tfminecraft.companionpets.pet.Activity.TOY_FOCUS
|| pet.staying() || pet.order() != net.tfminecraft.companionpets.pet.PetOrder.FOLLOW
|| pet.activity() == net.tfminecraft.companionpets.pet.Activity.SLEEPING
- || !runtime.followingAllowed(pet, Bukkit.getPlayer(pet.ownerId()))
+ || !runtime.followingAllowed(pet, runtime.owner(pet))
|| System.currentTimeMillis() < pet.forcedSitUntilMillis()) event.setCancelled(true);
}
@@ -90,7 +90,7 @@ private boolean interact(Player player, Entity entity) {
if (previous != null && previous.tick() == tick && previous.entity().equals(entity.getUniqueId())) return true;
boolean handled = actions.useOnPet(player, entity, player.getInventory().getItemInMainHand());
if (handled) lastClicks.put(player.getUniqueId(), new Click(entity.getUniqueId(), tick));
- return handled;
+ return handled || runtime.byEntity(entity) != null;
}
@EventHandler(priority = EventPriority.HIGHEST)
@@ -124,10 +124,11 @@ public void onChat(AsyncPlayerChatEvent event) {
String text = event.getMessage();
UUID playerId = player.getUniqueId();
Object prompt = runtime.sessions().privatePrompt(playerId);
+ var token = runtime.sessions().token(playerId);
if (prompt != null) event.setCancelled(true);
Bukkit.getScheduler().runTask(runtime.plugin(), () -> {
Player online = Bukkit.getPlayer(playerId);
- if (online != null && (prompt == null
+ if (online != null && net.tfminecraft.companionpets.ownership.CharacterAccess.current(token) && (prompt == null
? runtime.sessions().privatePrompt(playerId) == null
: runtime.sessions().privatePrompt(playerId) == prompt)) {
actions.onChat(online, text);
@@ -246,6 +247,7 @@ public void onBreak(BlockBreakEvent event) {
@EventHandler
public void onJoin(PlayerJoinEvent event) {
+ runtime.sessions().refresh(event.getPlayer().getUniqueId());
actions.ownerSessionChanged(event.getPlayer());
}
diff --git a/src/main/java/net/tfminecraft/companionpets/ownership/CharacterAccess.java b/src/main/java/net/tfminecraft/companionpets/ownership/CharacterAccess.java
new file mode 100644
index 0000000..564d134
--- /dev/null
+++ b/src/main/java/net/tfminecraft/companionpets/ownership/CharacterAccess.java
@@ -0,0 +1,54 @@
+package net.tfminecraft.companionpets.ownership;
+
+import java.util.Optional;
+import java.util.UUID;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import net.tfminecraft.companionpets.pet.Pet;
+
+/** Keeps older TLibs jars outside classes that use the new API. */
+public final class CharacterAccess {
+ private CharacterAccess() { }
+ private static Boolean apiPresent;
+ public static boolean available() {
+ if (apiPresent != null) return apiPresent;
+ try {
+ Class> characters = Class.forName("net.tfminecraft.tlibs.character.Characters");
+ characters.getMethod("legacyHeirsReady");
+ characters.getMethod("legacyHeir", UUID.class);
+ Class.forName("net.tfminecraft.tlibs.character.CharacterSessionStartEvent");
+ apiPresent = true;
+ } catch (ReflectiveOperationException | LinkageError ex) { apiPresent = false; }
+ return apiPresent;
+ }
+ public static OwnerSession capture(Player player) {
+ return player == null || !available() ? null : TLibsCharacters.capture(player);
+ }
+ public static boolean current(OwnerSession session) {
+ return session != null && available() && session.equals(capture(Bukkit.getPlayer(session.account())));
+ }
+ public static boolean owns(Pet pet, Player player) {
+ OwnerSession session = capture(player);
+ return session != null && !pet.ownershipHeld() && pet.ownerId().equals(session.account())
+ && session.character().equals(pet.ownerCharacterId());
+ }
+ public static boolean heirReady() { return available() && TLibsCharacters.heirReady(); }
+ public static Optional heir(UUID account) {
+ return available() ? TLibsCharacters.heir(account) : Optional.empty();
+ }
+ public static Player person(UUID character) {
+ return available() ? TLibsCharacters.person(character) : null;
+ }
+ public static String name(UUID character) {
+ return character == null || !available() ? "Unassigned character" : TLibsCharacters.name(character);
+ }
+ public static UUID resolve(String token) {
+ return available() ? TLibsCharacters.resolve(token) : null;
+ }
+ public static boolean alive(UUID character) {
+ return character != null && available() && TLibsCharacters.alive(character);
+ }
+ public static UUID account(UUID character) {
+ return available() ? TLibsCharacters.account(character) : null;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/companionpets/ownership/CharacterLifecycle.java b/src/main/java/net/tfminecraft/companionpets/ownership/CharacterLifecycle.java
new file mode 100644
index 0000000..007b619
--- /dev/null
+++ b/src/main/java/net/tfminecraft/companionpets/ownership/CharacterLifecycle.java
@@ -0,0 +1,34 @@
+package net.tfminecraft.companionpets.ownership;
+
+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 net.tfminecraft.companionpets.gui.MenuHolder;
+import net.tfminecraft.companionpets.runtime.PetActions;
+import net.tfminecraft.companionpets.runtime.PetRuntime;
+import net.tfminecraft.tlibs.character.CharacterSessionEndEvent;
+import net.tfminecraft.tlibs.character.CharacterSessionStartEvent;
+
+public final class CharacterLifecycle implements Listener {
+ private final PetRuntime runtime;
+ private final PetActions actions;
+ public CharacterLifecycle(PetRuntime runtime, PetActions actions) { this.runtime = runtime; this.actions = actions; }
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void end(CharacterSessionEndEvent event) {
+ Player player = Bukkit.getPlayer(event.getSession().ref().ownerId());
+ if (player != null) {
+ actions.ownerSessionChanged(player);
+ if (player.getOpenInventory().getTopInventory().getHolder() instanceof MenuHolder) player.closeInventory();
+ }
+ runtime.sessions().clearPlayer(event.getSession().ref().ownerId());
+ }
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void start(CharacterSessionStartEvent event) {
+ var account = event.getSession().ref().ownerId();
+ runtime.sessions().refresh(account);
+ Player player = Bukkit.getPlayer(account);
+ if (player != null) actions.ownerSessionChanged(player);
+ }
+}
diff --git a/src/main/java/net/tfminecraft/companionpets/ownership/DurableFiles.java b/src/main/java/net/tfminecraft/companionpets/ownership/DurableFiles.java
new file mode 100644
index 0000000..2577f82
--- /dev/null
+++ b/src/main/java/net/tfminecraft/companionpets/ownership/DurableFiles.java
@@ -0,0 +1,31 @@
+package net.tfminecraft.companionpets.ownership;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+
+public final class DurableFiles {
+ private DurableFiles() { }
+ public static void write(Path path, String contents) throws IOException {
+ Files.createDirectories(path.toAbsolutePath().getParent());
+ Path temp = path.resolveSibling(path.getFileName() + ".new");
+ try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.CREATE,
+ StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
+ ByteBuffer bytes = StandardCharsets.UTF_8.encode(contents);
+ while (bytes.hasRemaining()) channel.write(bytes);
+ channel.force(true);
+ }
+ Files.move(temp, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ // A newly created journal directory also needs its link in every parent persisted.
+ for (Path parent = path.toAbsolutePath().getParent(); parent != null; parent = parent.getParent()) {
+ try (FileChannel directory = FileChannel.open(parent, StandardOpenOption.READ)) {
+ directory.force(true);
+ }
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/companionpets/ownership/OwnerSession.java b/src/main/java/net/tfminecraft/companionpets/ownership/OwnerSession.java
new file mode 100644
index 0000000..08731b5
--- /dev/null
+++ b/src/main/java/net/tfminecraft/companionpets/ownership/OwnerSession.java
@@ -0,0 +1,6 @@
+package net.tfminecraft.companionpets.ownership;
+
+import java.util.UUID;
+
+/** Immutable authority token; safe to read from an async chat callback. */
+public record OwnerSession(UUID account, UUID character, long token) { }
diff --git a/src/main/java/net/tfminecraft/companionpets/ownership/OwnershipMigration.java b/src/main/java/net/tfminecraft/companionpets/ownership/OwnershipMigration.java
new file mode 100644
index 0000000..38036f1
--- /dev/null
+++ b/src/main/java/net/tfminecraft/companionpets/ownership/OwnershipMigration.java
@@ -0,0 +1,173 @@
+package net.tfminecraft.companionpets.ownership;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Function;
+import org.bukkit.configuration.InvalidConfigurationException;
+import org.bukkit.configuration.file.YamlConfiguration;
+
+/** Single-file expansion; immutable source and planned bytes precede publication. */
+public final class OwnershipMigration {
+ private final Path source;
+ private final Path folder;
+ private final Function> heirs;
+
+ public OwnershipMigration(Path source, Function> heirs) {
+ this.source = source;
+ this.folder = source.getParent().resolve("ownership-migrations");
+ this.heirs = heirs;
+ }
+
+ public void run() throws IOException {
+ Files.createDirectories(folder);
+ int next = 1;
+ try (var entries = Files.list(folder)) {
+ for (Path file : entries.filter(path -> path.getFileName().toString().matches("plan-[0-9]{4}.yml"))
+ .sorted().toList()) {
+ YamlConfiguration plan = read(file);
+ next = Math.max(next, Integer.parseInt(file.getFileName().toString().substring(5, 9)) + 1);
+ if (!plan.getBoolean("complete")) publish(file, plan);
+ }
+ }
+ if (!Files.exists(source)) return;
+ String original = Files.readString(source);
+ Map data = raw(original);
+ boolean changed = false;
+ if (data.get("pets") instanceof Map, ?> pets) {
+ for (Object value : pets.values()) {
+ if (!(value instanceof Map, ?> values)) continue;
+ @SuppressWarnings("unchecked") Map pet = (Map) values;
+ changed |= owner(pet);
+ changed |= carers(pet);
+ }
+ }
+ if (data.get("kennels") instanceof List> kennels) {
+ for (Object value : kennels) {
+ if (value instanceof Map, ?> values) {
+ @SuppressWarnings("unchecked") Map row = (Map) values;
+ changed |= owner(row);
+ }
+ }
+ }
+ if (!Integer.valueOf(2).equals(data.get("identity-version"))) { data.put("identity-version", 2); changed = true; }
+ if (!changed) return;
+ String name = "plan-%04d".formatted(next);
+ Path archive = folder.resolve(name + ".original.yml");
+ if (Files.exists(archive) && !hash(Files.readString(archive)).equals(hash(original)))
+ throw new IOException("BLOCKED: orphaned migration archive differs from source: " + archive);
+ if (!Files.exists(archive)) DurableFiles.write(archive, original);
+ if (!hash(Files.readString(archive)).equals(hash(original))) throw new IOException("Migration backup verification failed");
+ YamlConfiguration plan = new YamlConfiguration();
+ plan.set("before", hash(original));
+ plan.set("after", hash(dump(data)));
+ plan.set("planned", dump(data));
+ plan.set("complete", false);
+ Path file = folder.resolve(name + ".yml");
+ DurableFiles.write(file, plan.saveToString());
+ publish(file, plan);
+ }
+
+ private void publish(Path file, YamlConfiguration plan) throws IOException {
+ if (!Files.exists(source)) throw new IOException("BLOCKED: migration source is missing");
+ Path archive = file.resolveSibling(file.getFileName().toString().replace(".yml", ".original.yml"));
+ if (!Files.exists(archive) || !hash(Files.readString(archive)).equals(plan.getString("before")))
+ throw new IOException("BLOCKED: migration source archive is missing or corrupt");
+ String current = hash(Files.readString(source));
+ String planned = plan.getString("planned");
+ if (planned == null || !hash(planned).equals(plan.getString("after")))
+ throw new IOException("BLOCKED: migration planned bytes are corrupt");
+ if (current.equals(plan.getString("before"))) DurableFiles.write(source, planned);
+ else if (!current.equals(plan.getString("after")))
+ throw new IOException("BLOCKED: migration source changed after planning; preserve it for review");
+ plan.set("complete", true);
+ DurableFiles.write(file, plan.saveToString());
+ }
+
+ private boolean owner(Map row) {
+ if (row.containsKey("owner-character")) return false;
+ UUID account = uuid(row.get("owner"));
+ Optional heir = account == null ? Optional.empty() : heirs.apply(account);
+ if (heir.isEmpty()) return false;
+ row.put("owner-character", heir.get().toString());
+ return true;
+ }
+
+ private boolean carers(Map pet) {
+ boolean changed = false;
+ if (!Boolean.TRUE.equals(pet.get("carers-characters"))) {
+ if (pet.containsKey("carers")) pet.put("legacy-carers", pet.remove("carers"));
+ pet.put("carers-characters", true);
+ changed = true;
+ }
+ if (!(pet.get("legacy-carers") instanceof Map, ?> original)) return changed;
+ @SuppressWarnings("unchecked") Map legacy = (Map) original;
+ Map current;
+ if (pet.get("carers") instanceof Map, ?> existing) {
+ @SuppressWarnings("unchecked") Map memory = (Map) existing;
+ current = memory;
+ } else current = new LinkedHashMap<>();
+ for (String id : List.copyOf(legacy.keySet())) {
+ UUID account = uuid(id);
+ Optional heir = account == null ? Optional.empty() : heirs.apply(account);
+ if (heir.isEmpty()) continue;
+ String target = heir.get().toString();
+ Object memory = legacy.get(id);
+ if (current.containsKey(target) && !java.util.Objects.equals(current.get(target), memory)) continue;
+ current.put(target, memory); legacy.remove(id); changed = true;
+ }
+ if (!current.isEmpty()) pet.put("carers", current);
+ return changed;
+ }
+
+ @SuppressWarnings("unchecked")
+ public static Map raw(String text) throws IOException {
+ try {
+ var options = new org.yaml.snakeyaml.LoaderOptions();
+ options.setAllowDuplicateKeys(false);
+ options.setMaxAliasesForCollections(Integer.MAX_VALUE);
+ options.setCodePointLimit(Integer.MAX_VALUE);
+ Object data = new org.yaml.snakeyaml.Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(options)).load(text);
+ if (!(data instanceof Map, ?>)) throw new IOException("Ownership document must be a mapping");
+ Map document = (Map) data;
+ validateVersion(document);
+ return document;
+ } catch (org.yaml.snakeyaml.error.YAMLException ex) { throw new IOException("Unreadable ownership document", ex); }
+ }
+ private static void validateVersion(Map document) throws IOException {
+ if (!document.containsKey("identity-version")) return;
+ Object value = document.get("identity-version");
+ try {
+ if (!(value instanceof Number)) throw new ArithmeticException("Identity version must be numeric");
+ int version = new java.math.BigDecimal(value.toString()).intValueExact();
+ if (version != 1 && version != 2) throw new ArithmeticException("Unsupported identity version");
+ } catch (ArithmeticException | NumberFormatException ex) {
+ throw new IOException("Unsupported pet ownership schema; saving refused", ex);
+ }
+ }
+ public static String dump(Map data) { return new org.yaml.snakeyaml.Yaml().dump(data); }
+ public static UUID uuid(Object value) {
+ try { return value == null ? null : UUID.fromString(String.valueOf(value)); }
+ catch (IllegalArgumentException ex) { return null; }
+ }
+ public static YamlConfiguration read(Path path) throws IOException { return parse(Files.readString(path)); }
+ public static YamlConfiguration parse(String data) throws IOException {
+ YamlConfiguration yaml = new YamlConfiguration();
+ try { yaml.loadFromString(data); }
+ catch (InvalidConfigurationException ex) { throw new IOException("Unreadable ownership document", ex); }
+ return yaml;
+ }
+ public static String hash(String data) {
+ try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
+ .digest(data.getBytes(java.nio.charset.StandardCharsets.UTF_8))); }
+ catch (NoSuchAlgorithmException ex) { throw new IllegalStateException(ex); }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/companionpets/ownership/TLibsCharacters.java b/src/main/java/net/tfminecraft/companionpets/ownership/TLibsCharacters.java
new file mode 100644
index 0000000..5a3354d
--- /dev/null
+++ b/src/main/java/net/tfminecraft/companionpets/ownership/TLibsCharacters.java
@@ -0,0 +1,49 @@
+package net.tfminecraft.companionpets.ownership;
+
+import java.util.Optional;
+import java.util.UUID;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import net.tfminecraft.tlibs.character.Characters;
+
+final class TLibsCharacters {
+ private TLibsCharacters() { }
+ static OwnerSession capture(Player player) {
+ if (!player.isOnline()) return null;
+ return Characters.currentSession(player)
+ .filter(session -> session.ref().ownerId().equals(player.getUniqueId()))
+ .filter(session -> Characters.info(session.ref().characterId())
+ .filter(info -> info.alive() && info.ref().equals(session.ref())).isPresent())
+ .filter(Characters::isCurrent)
+ .map(session -> new OwnerSession(session.ref().ownerId(), session.ref().characterId(), session.sessionId()))
+ .orElse(null);
+ }
+ static boolean heirReady() { return Characters.legacyHeirsReady(); }
+ static Optional heir(UUID account) {
+ return Characters.legacyHeir(account).filter(ref -> ref.ownerId().equals(account))
+ .map(ref -> ref.characterId());
+ }
+ static Player person(UUID character) {
+ UUID account = account(character);
+ Player player = account == null ? null : Bukkit.getPlayer(account);
+ OwnerSession session = CharacterAccess.capture(player);
+ return session != null && session.character().equals(character) ? player : null;
+ }
+ static String name(UUID character) {
+ return Characters.info(character).map(info -> info.name()).orElse("Unknown character");
+ }
+ static UUID resolve(String token) {
+ UUID id = OwnershipMigration.uuid(token);
+ if (id != null) return Characters.info(id).map(info -> info.ref().characterId()).orElse(null);
+ var named = Characters.findByName(token);
+ if (named.size() == 1) return named.getFirst().ref().characterId();
+ var online = CharacterAccess.capture(Bukkit.getPlayerExact(token));
+ return online == null ? null : online.character();
+ }
+ static boolean alive(UUID character) {
+ return Characters.info(character).filter(info -> info.alive()).isPresent();
+ }
+ static UUID account(UUID character) {
+ return character == null ? null : Characters.info(character).map(info -> info.ref().ownerId()).orElse(null);
+ }
+}
diff --git a/src/main/java/net/tfminecraft/companionpets/pet/Pet.java b/src/main/java/net/tfminecraft/companionpets/pet/Pet.java
index f9f957e..247297e 100644
--- a/src/main/java/net/tfminecraft/companionpets/pet/Pet.java
+++ b/src/main/java/net/tfminecraft/companionpets/pet/Pet.java
@@ -13,6 +13,10 @@
public final class Pet {
private final UUID id;
private UUID ownerId;
+ private UUID ownerCharacterId;
+ private boolean ownershipHeld;
+ private java.util.function.BooleanSupplier persistenceHeld = () -> false;
+ public void persistenceHeld(java.util.function.BooleanSupplier guard) { persistenceHeld = guard; }
private final String typeId;
private String name;
private PetSex sex;
@@ -91,6 +95,18 @@ public UUID ownerId() {
return ownerId;
}
+ public UUID ownerCharacterId() { return ownerCharacterId; }
+ public boolean ownershipHeld() { return ownershipHeld || persistenceHeld.getAsBoolean(); }
+ public void ownershipHeld(boolean held) { ownershipHeld = held; }
+
+ /** Ownership changes retain this pet's physiology, learning and relationships. */
+ public void ownership(UUID account, UUID character) {
+ ownerId = java.util.Objects.requireNonNull(account);
+ ownerCharacterId = character;
+ changed();
+ reindex();
+ }
+
public void ownerId(UUID ownerId) {
boolean changed = !this.ownerId.equals(ownerId);
if (changed) {
diff --git a/src/main/java/net/tfminecraft/companionpets/play/FetchJob.java b/src/main/java/net/tfminecraft/companionpets/play/FetchJob.java
index 83b904a..dfadfcb 100644
--- a/src/main/java/net/tfminecraft/companionpets/play/FetchJob.java
+++ b/src/main/java/net/tfminecraft/companionpets/play/FetchJob.java
@@ -8,6 +8,10 @@ public final class FetchJob {
private final String toy;
private final UUID id = UUID.randomUUID();
private final UUID throwerId;
+ private final net.tfminecraft.companionpets.ownership.OwnerSession session;
+ public org.bukkit.entity.Player thrower() {
+ return net.tfminecraft.companionpets.ownership.CharacterAccess.current(session) ? org.bukkit.Bukkit.getPlayer(throwerId) : null;
+ }
private final Set favorites = new HashSet<>();
private final Set chasers = new java.util.LinkedHashSet<>();
private final java.util.Map speeds = new java.util.HashMap<>();
@@ -29,9 +33,12 @@ public static final class Stalk {
private UUID itemId;
private long missingSince;
- public FetchJob(String toy, UUID throwerId) {
+ public FetchJob(String toy, UUID throwerId) { this(toy, throwerId, null); }
+
+ public FetchJob(String toy, UUID throwerId, net.tfminecraft.companionpets.ownership.OwnerSession session) {
this.toy = toy;
this.throwerId = throwerId;
+ this.session = session;
this.phase = FetchPhase.AIR;
}
diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetActions.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetActions.java
index 88245f3..ca18f2f 100644
--- a/src/main/java/net/tfminecraft/companionpets/runtime/PetActions.java
+++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetActions.java
@@ -164,7 +164,7 @@ public void clearInteractions() {
public void ownerSessionChanged(Player player) {
for (Pet pet : runtime.store().of(player.getUniqueId())) {
- if (pet.fetch() != null) continue;
+ if (pet.fetch() != null) releaseFetch(pet, player, false);
roaming.cancelWithPosture(pet);
clearInteractions(pet);
pauseRestoredFollowing(pet, runtime.entity(pet));
@@ -173,13 +173,13 @@ public void ownerSessionChanged(Player player) {
private void pauseRestoredFollowing(Pet pet, Entity entity) {
if (entity instanceof Mob mob && pet.order() == PetOrder.FOLLOW
- && !runtime.followingAllowed(pet, Bukkit.getPlayer(pet.ownerId())))
+ && !runtime.followingAllowed(pet, runtime.owner(pet)))
PostureNavigationGoal.hold(runtime, pet, mob);
}
private boolean calmInteraction(Player player, Pet pet) {
boolean hostile = social.status(pet) != null || vanillaTarget(pet) != null;
- if (!hostile || !(pet.ownerId().equals(player.getUniqueId())
+ if (!hostile || !(runtime.owns(pet, player)
|| vanillaTarget(pet) == player || player.hasPermission("companionpets.test"))) return false;
long now = System.currentTimeMillis();
CalmProgress progress = calming.get(pet.id());
@@ -216,7 +216,7 @@ private Player vanillaTarget(Pet pet) {
public boolean triggerTestMoment(Player player, String moment) {
Entity target = lookingAt(player, 6.0);
Pet pet = runtime.byEntity(target);
- if (pet == null || pet.stored() || !pet.ownerId().equals(player.getUniqueId()) || !(target instanceof Mob body)) {
+ if (pet == null || pet.stored() || !runtime.owns(pet, player) || !(target instanceof Mob body)) {
PetFx.tell(player, "Look at one of your pets that is outside the Pet House first.");
return false;
}
@@ -269,13 +269,14 @@ public boolean useOnPet(Player player, Entity entity, ItemStack hand) {
if (pet == null || pet.stored()) {
return false;
}
+ if (pet.ownershipHeld() || pet.ownerCharacterId() == null) { PetFx.bar(player, "This pet is waiting for reviewed ownership or recovery"); return true; }
PetTypeDef type = runtime.config().type(pet.typeId());
if (type == null) {
return false;
}
try (HeldItem item = HeldItem.of(hand).scope()) {
Material held = hand == null ? Material.AIR : hand.getType();
- boolean owner = pet.ownerId().equals(player.getUniqueId());
+ boolean owner = runtime.owns(pet, player);
boolean favorite = type.isTreat(item);
long now = System.currentTimeMillis();
if (careActions.groom(player, pet, entity, hand, type, now)) return true;
@@ -327,7 +328,7 @@ public boolean useOnPet(Player player, Entity entity, ItemStack hand) {
}
private boolean ownerNearby(Pet pet) {
- Player owner = Bukkit.getPlayer(pet.ownerId());
+ Player owner = runtime.owner(pet);
if (owner == null || !owner.isOnline()) {
return false;
}
@@ -340,7 +341,7 @@ private void checkIn(Player player, Pet pet, Entity entity, PetTypeDef type, lon
cheer(pet, now, PET_MOOD_GAIN);
return;
}
- if (!pet.ownerId().equals(player.getUniqueId())) return;
+ if (!runtime.owns(pet, player)) return;
runtime.visual().cancelAction(entity);
}
PetFx.look(entity, player);
@@ -399,7 +400,7 @@ public void useWorld(Player player, ItemStack hand, HeldItem held, Block clicked
if (clicked != null && isKennel(clicked) && !sneaking) {
UUID owner = runtime.store().kennelOwner(PetStore.kennelKey(
clicked.getWorld().getName(), clicked.getX(), clicked.getY(), clicked.getZ()));
- if (owner != null && !owner.equals(player.getUniqueId())) {
+ if (owner != null && !runtime.ownsKennel(PetStore.kennelKey(clicked.getWorld().getName(), clicked.getX(), clicked.getY(), clicked.getZ()), player)) {
PetFx.bar(player, "This Pet House belongs to someone else");
return;
}
@@ -438,6 +439,7 @@ public boolean handledWorld(Player player, ItemStack hand, HeldItem held, Block
}
public void clickMenu(Player player, MenuHolder holder, int slot, ItemStack current, boolean rightClick, boolean shift, boolean lettingGo) {
+ if (!holder.current(player)) return;
if (holder.kind() == MenuHolder.Kind.KENNEL) {
if (slot == 53) {
if (net.tfminecraft.companionpets.gui.MenuNavigation.turn(player, holder.page(), holder.pages(), rightClick))
@@ -448,7 +450,7 @@ public void clickMenu(Player player, MenuHolder holder, int slot, ItemStack curr
if ((holder.kind() == MenuHolder.Kind.TRICK || holder.kind() == MenuHolder.Kind.LEARNED)
&& slot == PetMenus.TRICKS_NEXT_SLOT) {
Pet pet = runtime.store().get(holder.petId());
- if (pet != null && (holder.kind() == MenuHolder.Kind.LEARNED || pet.ownerId().equals(player.getUniqueId()))) {
+ if (pet != null && (holder.kind() == MenuHolder.Kind.LEARNED || runtime.owns(pet, player))) {
if (!net.tfminecraft.companionpets.gui.MenuNavigation.turn(player, holder.page(), holder.pages(), rightClick)) return;
int page = holder.page() + (rightClick ? -1 : 1);
holder.navigating(true);
@@ -473,7 +475,7 @@ public void clickMenu(Player player, MenuHolder holder, int slot, ItemStack curr
if (holder.kind() == MenuHolder.Kind.TRICK) {
if (slot == PetMenus.TRICKS_BACK_SLOT) {
Pet pet = runtime.store().get(holder.petId());
- if (pet != null && pet.ownerId().equals(player.getUniqueId())) menus.openCare(player, pet, holder.petHouseBack(), holder.petHousePage(), holder.house());
+ if (pet != null && runtime.owns(pet, player)) menus.openCare(player, pet, holder.petHouseBack(), holder.petHousePage(), holder.house());
} else clickTrick(player, holder, slot);
return;
}
@@ -493,7 +495,7 @@ public void clickMenu(Player player, MenuHolder holder, int slot, ItemStack curr
return;
}
Pet pet = runtime.store().get(petId);
- if (pet == null || !pet.ownerId().equals(player.getUniqueId())) {
+ if (pet == null || !runtime.owns(pet, player)) {
return;
}
menus.openCare(player, pet, true, holder.page(), holder.house());
@@ -502,7 +504,7 @@ public void clickMenu(Player player, MenuHolder holder, int slot, ItemStack curr
private void clickCare(Player player, MenuHolder holder, int slot) {
Pet pet = runtime.store().get(holder.petId());
if (pet == null) return;
- boolean owner = pet.ownerId().equals(player.getUniqueId());
+ boolean owner = runtime.owns(pet, player);
int tricksSlot = owner ? PetMenus.careSlot(PetMenus.TRICKS_SLOT, holder.petHouseBack(), pet.stored()) : PetMenus.TRICKS_SLOT;
if (slot == tricksSlot) {
menus.openLearned(player, pet);
@@ -543,7 +545,7 @@ public void onChat(Player player, String text) {
if (session == null && runtime.store().countOut(player.getUniqueId()) == 0) return;
if (respondToName(player, text, now) || addressedOrder(player, text, now)) return;
String word = SpokenOrder.key(text);
- if (session == null && runtime.store().of(player.getUniqueId()).stream()
+ if (session == null && runtime.owned(player).stream()
.noneMatch(pet -> !pet.stored() && pet.trickFor(word) != null)) return;
Entity looked = lookingAt(player, 6.0);
training.handleTrainingChat(player, text, now, runtime.byEntity(looked), looked);
@@ -551,13 +553,13 @@ public void onChat(Player player, String text) {
private boolean audible(Player player, Pet pet) {
Entity body = runtime.entity(pet);
- return pet.ownerId().equals(player.getUniqueId()) && !pet.stored() && !pet.dead()
+ return runtime.owns(pet, player) && !pet.stored() && !pet.dead()
&& body instanceof Mob && body.getWorld().equals(player.getWorld())
&& body.getLocation().distanceSquared(player.getLocation()) <= Math.pow(runtime.config().hearingRadius(), 2);
}
private boolean addressedOrder(Player player, String text, long now) {
- var matches = runtime.store().of(player.getUniqueId()).stream().filter(p -> audible(player, p))
+ var matches = runtime.owned(player).stream().filter(p -> audible(player, p))
.filter(p -> SpokenOrder.addressedCommand(text, p.name()) != null).toList();
if (matches.isEmpty()) return false;
int longest = matches.stream().mapToInt(p -> SpokenOrder.key(p.name()).length()).max().orElse(0);
@@ -576,7 +578,7 @@ private boolean addressedOrder(Player player, String text, long now) {
private boolean respondToName(Player player, String text, long now) {
boolean answered = false;
- for (Pet pet : runtime.store().of(player.getUniqueId())) {
+ for (Pet pet : runtime.owned(player)) {
if (!audible(player, pet)
|| !SpokenOrder.matches(text, pet.name())) continue;
Entity entity = runtime.entity(pet);
@@ -614,6 +616,7 @@ public void reattach(Entity entity) {
runtime.visual().removeBody(entity);
return;
}
+ runtime.bodies().bindOwner(entity, pet);
PetTypeDef type = runtime.config().type(pet.typeId());
if (type == null) {
runtime.remember(pet, entity);
@@ -624,7 +627,7 @@ public void reattach(Entity entity) {
}
if (!runtime.bodies().compatible(entity, type)) {
// Prepare a replacement before removing the old body. Pet identity and saved care never change.
- Entity replacement = runtime.bodies().spawn(pet, type, entity.getLocation(), Bukkit.getPlayer(pet.ownerId()));
+ Entity replacement = runtime.bodies().spawn(pet, type, entity.getLocation(), runtime.owner(pet));
if (replacement == null) {
runtime.remember(pet, entity);
if (entity instanceof Mob mob) net.tfminecraft.companionpets.integration.PetMotion.hold(mob);
@@ -662,7 +665,7 @@ public Entity restoreBody(Pet pet) {
}
}
Location location = new Location(world, pet.x(), pet.y(), pet.z(), pet.yaw(), 0);
- body = runtime.bodies().spawn(pet, runtime.config().type(pet.typeId()), location, Bukkit.getPlayer(pet.ownerId()));
+ body = runtime.bodies().spawn(pet, runtime.config().type(pet.typeId()), location, runtime.owner(pet));
if (body != null) {
pet.clearRuntimeMotion();
runtime.remember(pet, body);
@@ -683,7 +686,7 @@ private void clickTrick(Player player, MenuHolder holder, int slot) {
return;
}
Pet pet = runtime.store().get(holder.petId());
- if (pet == null || !pet.ownerId().equals(player.getUniqueId())) {
+ if (pet == null || !runtime.owns(pet, player)) {
return;
}
bindTrick(player, pet, holder.word(), selected);
@@ -700,7 +703,7 @@ private boolean handleRenameChat(Player player, String text, long now) {
return false;
}
Pet pet = runtime.store().get(prompt.petId());
- if (pet == null || now > prompt.expiresAt() || !pet.ownerId().equals(player.getUniqueId())) {
+ if (pet == null || now > prompt.expiresAt() || !runtime.owns(pet, player)) {
runtime.sessions().clearRename(player.getUniqueId());
return true;
}
@@ -858,7 +861,7 @@ private void wakeToFollow(Pet pet, long now) {
}
public void lostBody(Pet pet) {
- releaseFetch(pet, Bukkit.getPlayer(pet.ownerId()), true);
+ releaseFetch(pet, runtime.owner(pet), true);
Entity dying = pet.entityId() == null ? null : Bukkit.getEntity(pet.entityId());
if (pet.carriedToy() != null && dying != null) {
dropPlain(dying.getLocation(), pet.carriedToy());
@@ -874,7 +877,7 @@ public void lostBody(Pet pet) {
markSleep(body, false);
}
runtime.store().requestSave();
- Player owner = Bukkit.getPlayer(pet.ownerId());
+ Player owner = runtime.owner(pet);
if (owner != null && owner.isOnline()) {
PetFx.tell(owner, pet.name() + " has died");
}
@@ -885,7 +888,7 @@ public void struck(Pet pet, Entity body) {
if (body != null) {
PetFx.particle(body, Particle.CRIT, 6);
}
- Player owner = Bukkit.getPlayer(pet.ownerId());
+ Player owner = runtime.owner(pet);
if (owner != null && owner.isOnline()) {
PetFx.bar(owner, PetTexts.struck(pet.name()));
}
@@ -919,7 +922,7 @@ private void takeOut(Player player, Pet pet, Location house) {
}
private void releasePet(Player player, Pet pet) {
- if (!pet.ownerId().equals(player.getUniqueId())) return;
+ if (!runtime.owns(pet, player)) return;
if (!runtime.store().remove(pet.id())) {
PetFx.tell(player, "Could not save the release. Your pet is still with you; contact an administrator.");
return;
@@ -943,7 +946,7 @@ private void releasePet(Player player, Pet pet) {
}
private void beginRelease(Player player, Pet pet) {
- if (!pet.ownerId().equals(player.getUniqueId())) return;
+ if (!runtime.owns(pet, player)) return;
runtime.sessions().release(player.getUniqueId(), new ReleasePrompt(pet.id(), System.currentTimeMillis() + PROMPT_MILLIS));
player.closeInventory();
PetFx.tell(player, "Release " + pet.name() + " forever? Type \"yes\" to confirm, or \"no\" to cancel.");
@@ -956,7 +959,7 @@ private boolean handleReleaseChat(Player player, String text, long now) {
return false;
}
Pet pet = runtime.store().get(prompt.petId());
- if (pet == null || !pet.ownerId().equals(playerId)) {
+ if (pet == null || !runtime.owns(pet, player)) {
runtime.sessions().clearRelease(playerId);
PetFx.tell(player, "That pet is no longer available to release.");
return true;
@@ -1001,7 +1004,9 @@ public boolean spawnTestPet(Player player, String typeId, String name) {
if (safeName.isBlank()) {
safeName = Names.sanitize(type.id());
}
+ if (runtime.characterId(player) == null) { PetFx.tell(player, "You need an active character to create a pet."); return false; }
Pet pet = new Pet(UUID.randomUUID(), player.getUniqueId(), type.id(), safeName, PetSex.MALE);
+ pet.ownership(player.getUniqueId(), runtime.characterId(player));
net.tfminecraft.companionpets.training.DefaultTricks.apply(runtime.config(), pet);
pet.bornAt(System.currentTimeMillis());
pet.favoriteToy(FavoriteToy.reconcile(null, toyNames(type), runtime.random()).toy());
@@ -1056,6 +1061,7 @@ private void storePet(Player player, Pet pet) {
}
private void placeKennel(Player player, ItemStack hand, Block clicked, BlockFace face) {
+ if (runtime.characterId(player) == null) { PetFx.bar(player, "You need an active character to place a Pet House"); return; }
Block place = clicked.getRelative(face);
if (!place.getType().isAir() && !place.isReplaceable()) {
PetFx.bar(player, "There isn't enough room for a Pet House there");
@@ -1075,7 +1081,7 @@ private void placeKennel(Player player, ItemStack hand, Block clicked, BlockFace
if (!accepted) replaced.update(true, false);
}
place.getState().update(true, true);
- runtime.store().kennel(PetStore.kennelKey(place.getWorld().getName(), place.getX(), place.getY(), place.getZ()), player.getUniqueId());
+ runtime.store().kennel(PetStore.kennelKey(place.getWorld().getName(), place.getX(), place.getY(), place.getZ()), player.getUniqueId(), runtime.characterId(player));
runtime.store().requestSave();
PetFx.bar(player, "Pet House placed. Right-click it to look after your pets");
}
diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetFetchActions.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetFetchActions.java
index 4da2aea..6179d52 100644
--- a/src/main/java/net/tfminecraft/companionpets/runtime/PetFetchActions.java
+++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetFetchActions.java
@@ -52,10 +52,11 @@ final class PetFetchActions {
}
void throwToy(Player player, ItemStack hand, HeldItem held) {
+ if (net.tfminecraft.companionpets.ownership.CharacterAccess.capture(player) == null) return;
var accepting = runtime.config().toyTypes(held);
ItemStack thrown = hand.clone();
thrown.setAmount(1);
- FetchJob job = new FetchJob(ToyItems.encode(thrown), player.getUniqueId());
+ FetchJob job = new FetchJob(ToyItems.encode(thrown), player.getUniqueId(), net.tfminecraft.companionpets.ownership.CharacterAccess.capture(player));
if (!HandItems.consume(player, hand)) return;
Location eye = player.getEyeLocation();
Vector velocity = eye.getDirection().normalize().multiply(ThrowSpeed.speed(
@@ -139,7 +140,7 @@ void step(Pet pet, Mob mob) {
void step(Pet pet, Mob mob, long now) {
FetchJob job = pet.fetch();
- Player owner = Bukkit.getPlayer(job.throwerId());
+ Player owner = job.thrower();
if (owner == null || !owner.isOnline()) {
actions.releaseFetch(pet, null, false);
return;
@@ -322,11 +323,11 @@ Location destination(Pet pet) {
FetchJob job = pet.fetch();
if (job == null) return null;
if (job.phase() == FetchPhase.CARRY && !pet.id().equals(job.carrierId()))
- return carrierDestination(pet, job, Bukkit.getPlayer(job.throwerId()));
+ return carrierDestination(pet, job, job.thrower());
Entity target = switch (job.phase()) {
case AIR -> entity(job.projectileId());
case GROUND -> entity(job.itemId());
- case CARRY -> Bukkit.getPlayer(job.throwerId());
+ case CARRY -> job.thrower();
};
return target == null ? null : target.getLocation();
}
@@ -374,7 +375,7 @@ void releaseFetch(Pet pet, boolean toOwner) {
if (job == null) return;
detach(pet);
if (pet.id().equals(job.carrierId())) {
- Player thrower = Bukkit.getPlayer(job.throwerId());
+ Player thrower = job.thrower();
if (thrower != null && thrower.isOnline() && toOwner) {
finish(job, PetRuntime.inFront(thrower));
} else if (job.throwerId().equals(pet.ownerId())) {
@@ -399,7 +400,7 @@ void tick(long now) {
for (FetchJob job : List.copyOf(throwsInProgress.values())) {
for (Pet pet : chasers(job)) {
if (runtime.entity(pet) == null || !canChase(pet)
- || !pet.ownerId().equals(job.throwerId()) && !sharedFetchAllowed(pet, job)) releaseFetch(pet, false);
+ || !runtime.owns(pet, job.thrower()) && !sharedFetchAllowed(pet, job)) releaseFetch(pet, false);
}
if (!throwsInProgress.containsKey(job.id())) continue;
Entity target = job.phase() == FetchPhase.AIR ? entity(job.projectileId()) : entity(job.itemId());
@@ -427,7 +428,7 @@ void tick(long now) {
}
private boolean sharedFetchAllowed(Pet pet, FetchJob job) {
- Player thrower = Bukkit.getPlayer(job.throwerId());
+ Player thrower = job.thrower();
return thrower != null && actions.anticipation().strangerAllowed(pet, thrower);
}
void stashLooseToys() {
@@ -486,7 +487,7 @@ private void finish(FetchJob job, Location at) {
}
private void returnFollower(Pet pet, double speed) {
- Player owner = Bukkit.getPlayer(pet.ownerId());
+ Player owner = runtime.owner(pet);
Entity body = runtime.entity(pet);
if (canChase(pet) && owner != null && owner.isOnline() && body != null && body.getWorld().equals(owner.getWorld()))
actions.roaming().returnFromFetch(pet, owner, speed);
diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetGreetings.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetGreetings.java
index 8d2a2a4..ed5d273 100644
--- a/src/main/java/net/tfminecraft/companionpets/runtime/PetGreetings.java
+++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetGreetings.java
@@ -38,7 +38,7 @@ void tick(long now) {
}
// Saved pets are stamped when they leave the Pet House, so they need no tick here.
for (Pet pet : runtime.store().active()) {
- Player owner = Bukkit.getPlayer(pet.ownerId());
+ Player owner = runtime.owner(pet);
boolean near = owner != null && owner.isOnline()
&& runtime.distance(owner, pet) <= runtime.config().greeting().nearRadius();
if (pet.lastOwnerNearbyMillis() == 0) {
@@ -63,19 +63,19 @@ private void recognizeCarers(Pet pet, long now) {
if (pet.stored() || pet.dead() || !runtime.behaves(pet, PetBehavior.RECOGNIZE_CARERS)
|| !(runtime.entity(pet) instanceof Mob body)) return;
for (var entry : List.copyOf(pet.carers().entries().entrySet())) {
- Player person = Bukkit.getPlayer(entry.getKey());
+ Player person = runtime.person(entry.getKey());
if (person == null || !person.isOnline() || !person.getWorld().equals(body.getWorld())
- || person.getUniqueId().equals(pet.ownerId()) || runtime.distance(person, pet) > 6) continue;
+ || runtime.owns(pet, person) || runtime.distance(person, pet) > 6) continue;
var memory = entry.getValue();
if (!active(pet) && memory.trust() >= net.tfminecraft.companionpets.pet.RelationshipMemory.FAMILIAR_AT
&& now - memory.nearbyAt() >= 60_000 && now - memory.greetedAt() >= 120_000
- && trigger(pet, person, now)) pet.carers().greeted(person.getUniqueId(), now);
- pet.carers().nearby(person.getUniqueId(), now);
+ && trigger(pet, person, now)) pet.carers().greeted(runtime.characterId(person), now);
+ pet.carers().nearby(runtime.characterId(person), now);
}
}
void ownerDeparted(Player owner, long now) {
- for (Pet pet : runtime.store().of(owner.getUniqueId())) {
+ for (Pet pet : runtime.owned(owner)) {
if (!pet.stored() && runtime.distance(owner, pet) <= runtime.config().greeting().nearRadius())
pet.lastOwnerNearbyMillis(now);
}
@@ -85,8 +85,8 @@ void ownerDeparted(Player owner, long now) {
boolean trigger(Pet pet, Player owner, long now) {
if (!(runtime.entity(pet) instanceof Mob body) || !ready(pet, body, owner, now)) return false;
- boolean reunion = pet.ownerId().equals(owner.getUniqueId());
- GreetingMood feeling = GreetingMood.of(pet, reunion ? pet.bond() : pet.carers().trust(owner.getUniqueId()));
+ boolean reunion = runtime.owns(pet, owner);
+ GreetingMood feeling = GreetingMood.of(pet, reunion ? pet.bond() : pet.carers().trust(runtime.characterId(owner)));
boolean mobile = reunion && feeling.mobile() && (runtime.behaves(pet, PetBehavior.GREETING_CIRCLES)
|| runtime.behaves(pet, PetBehavior.GREETING_APPROACH));
actions.clearInteractions(pet);
@@ -125,9 +125,9 @@ private boolean ready(Pet pet, Mob body, Player owner, long now) {
&& !pet.stored() && !pet.dead() && pet.fetch() == null
&& (pet.activity() == Activity.NONE || pet.activity() == Activity.SLEEPING)
&& body.isValid() && !body.isDead() && !body.isInWater() && body.getTarget() == null
- && owner != null && owner.isOnline() && (pet.ownerId().equals(owner.getUniqueId())
- || runtime.behaves(pet, PetBehavior.RECOGNIZE_CARERS) && pet.carers().familiar(owner.getUniqueId()))
- && (pet.ownerId().equals(owner.getUniqueId()) || !runtime.visual().holdsMovement(body) && !actions.social().engaged(pet))
+ && owner != null && owner.isOnline() && (runtime.owns(pet, owner)
+ || runtime.behaves(pet, PetBehavior.RECOGNIZE_CARERS) && pet.carers().familiar(runtime.characterId(owner)))
+ && (runtime.owns(pet, owner) || !runtime.visual().holdsMovement(body) && !actions.social().engaged(pet))
&& body.getWorld().equals(owner.getWorld())
&& runtime.distance(owner, pet) <= runtime.config().greeting().nearRadius()
&& !runtime.sessions().resting(pet.id(), now)
@@ -145,7 +145,7 @@ void advance(Pet pet, long now) {
|| pet.staying() || now < pet.forcedSitUntilMillis() || !mood(pet, greeting).mobile()
: pet.activity() != greeting.previousActivity || pet.order() != greeting.previousOrder
|| pet.staying() != greeting.previousStaying || pet.forcedSitUntilMillis() != greeting.previousForcedSit)
- || !owner.isOnline() || !pet.ownerId().equals(greeting.ownerId)
+ || !owner.isOnline() || !net.tfminecraft.companionpets.ownership.CharacterAccess.current(greeting.session) || !pet.ownerId().equals(greeting.ownerId)
|| !body.isValid() || body.isDead() || runtime.entity(pet) != body
|| !owner.getWorld().equals(body.getWorld()) || body.getTarget() != null || body.isInWater()
|| !runtime.behaves(pet, PetBehavior.GREETING) || !runtime.config().moments().enabled() || !runtime.config().greeting().enabled()
@@ -344,7 +344,7 @@ void clear() {
private static long millis(double seconds) { return Math.round(seconds * 1000); }
private static double square(double value) { return value * value; }
private GreetingMood mood(Pet pet, Greeting job) {
- return GreetingMood.of(pet, job.reunion ? pet.bond() : pet.carers().trust(job.owner.getUniqueId()));
+ return GreetingMood.of(pet, job.reunion ? pet.bond() : pet.carers().trust(runtime.characterId(job.owner)));
}
private boolean occupiedSlot(UUID recipient, int slot) {
return active.values().stream().anyMatch(g -> g.mobile && g.owner.getUniqueId().equals(recipient) && g.slot == slot);
@@ -382,9 +382,11 @@ private static final class Greeting {
int meows;
long pauseUntil;
+ private final net.tfminecraft.companionpets.ownership.OwnerSession session;
Greeting(Mob body, Player owner, Pet pet, long until, double angle, int direction, boolean mobile, boolean reunion, int slot) {
this.body = body;
this.owner = owner;
+ this.session = net.tfminecraft.companionpets.ownership.CharacterAccess.capture(owner);
this.until = until;
this.angle = angle;
this.direction = direction;
diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetHatching.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetHatching.java
index a001d26..3c133a5 100644
--- a/src/main/java/net/tfminecraft/companionpets/runtime/PetHatching.java
+++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetHatching.java
@@ -25,6 +25,7 @@ final class PetHatching {
private final PetRuntime runtime;
PetHatching(PetRuntime runtime) { this.runtime = runtime; }
void begin(Player player, PetTypeDef type) {
+ if (runtime.characterId(player) == null) { PetFx.tell(player, "You need an active character to hatch a pet."); return; }
if (!type.canHatch(player)) {
PetFx.bar(player, NOT_ALLOWED);
return;
@@ -122,6 +123,7 @@ private void finishHatch(Player player, PetTypeDef type, String name, PetSex sex
return;
}
Pet pet = new Pet(UUID.randomUUID(), player.getUniqueId(), type.id(), name, sex);
+ pet.ownership(player.getUniqueId(), runtime.characterId(player));
net.tfminecraft.companionpets.training.DefaultTricks.apply(runtime.config(), pet);
pet.bornAt(System.currentTimeMillis());
FavoriteToy.Result favorite = FavoriteToy.reconcile(null, PetActions.toyNames(type), runtime.random());
diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetMoments.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetMoments.java
index db661e7..7fa9670 100644
--- a/src/main/java/net/tfminecraft/companionpets/runtime/PetMoments.java
+++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetMoments.java
@@ -47,7 +47,7 @@ private boolean bellyReady(Pet pet, Mob body, Player owner) {
&& pet.need(Need.MOOD) >= runtime.config().belly().minMood()
&& pet.activity() == Activity.NONE && pet.fetch() == null && !gifts.containsKey(pet)
&& body.isValid() && body.isOnGround() && !body.isInWater() && body.getTarget() == null
- && owner != null && owner.isOnline() && owner.getUniqueId().equals(pet.ownerId())
+ && owner != null && owner.isOnline() && runtime.owns(pet, owner)
&& owner.getWorld().equals(body.getWorld()) && owner.getLocation().distanceSquared(body.getLocation()) <= 36
&& !runtime.sessions().resting(pet.id(), System.currentTimeMillis())
&& !(runtime.sessions().training(owner.getUniqueId()) instanceof TrainingSession s && s.petId().equals(pet.id()));
diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetRoaming.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetRoaming.java
index c61e206..964f922 100644
--- a/src/main/java/net/tfminecraft/companionpets/runtime/PetRoaming.java
+++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetRoaming.java
@@ -97,7 +97,7 @@ void returnFromFetch(Pet pet, Player owner, double speed) {
Location destination(Pet pet) {
Attention job = attention.get(pet.id());
- Player owner = job == null ? null : Bukkit.getPlayer(job.ownerId);
+ Player owner = job == null || !net.tfminecraft.companionpets.ownership.CharacterAccess.current(job.session) ? null : Bukkit.getPlayer(job.ownerId);
return owner != null && owner.isOnline() ? owner.getLocation() : null;
}
@@ -124,7 +124,7 @@ boolean tickAttention(Pet pet, Mob body, long now) {
cancelAttention(pet);
return false;
}
- Player owner = Bukkit.getPlayer(job.ownerId);
+ Player owner = net.tfminecraft.companionpets.ownership.CharacterAccess.current(job.session) ? Bukkit.getPlayer(job.ownerId) : null;
if (owner == null || !owner.isOnline() || now >= (job.waitUntil == 0 ? job.until : job.waitUntil)
|| !owner.getWorld().equals(body.getWorld())) {
cancelAttention(pet);
@@ -221,12 +221,13 @@ private static final class Attention {
private boolean fetchReturn;
private double speed = 1.25;
private final UUID ownerId;
+ private final net.tfminecraft.companionpets.ownership.OwnerSession session;
private long until;
private final PetOrder returnOrder;
private long waitUntil;
private boolean greeted;
private Location routeTarget;
private long routeAt;
- private Attention(UUID ownerId, long until, PetOrder returnOrder) { this.ownerId = ownerId; this.until = until; this.returnOrder = returnOrder; }
+ private Attention(UUID ownerId, long until, PetOrder returnOrder) { this.ownerId = ownerId; this.session = net.tfminecraft.companionpets.ownership.CharacterAccess.capture(Bukkit.getPlayer(ownerId)); this.until = until; this.returnOrder = returnOrder; }
}
}
diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetRuntime.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetRuntime.java
index 4bd933d..b3595d7 100644
--- a/src/main/java/net/tfminecraft/companionpets/runtime/PetRuntime.java
+++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetRuntime.java
@@ -48,6 +48,7 @@ public PetRuntime(
this.config = config;
this.store = store;
this.sessions = sessions;
+ sessions.enforceCharacters();
this.bodies = bodies;
this.visual = visual;
this.petKey = petKey;
@@ -93,7 +94,7 @@ public Sessions sessions() {
public boolean trainingFocused(Pet pet, org.bukkit.entity.Mob body) {
var session = sessions.training(pet.ownerId());
return session != null && session.petId().equals(pet.id())
- && TrainingNavigationGoal.focused(this, pet, body, Bukkit.getPlayer(pet.ownerId()));
+ && TrainingNavigationGoal.focused(this, pet, body, owner(pet));
}
public Bodies bodies() {
@@ -152,13 +153,32 @@ public net.tfminecraft.companionpets.visual.PetCapabilities capabilities(net.tfm
}
public void recordCare(Player player, Pet pet, double gain, long now) {
- if (!pet.ownerId().equals(player.getUniqueId())
+ if (characterId(player) != null && !owns(pet, player)
&& behaves(pet, net.tfminecraft.companionpets.config.PetBehavior.RECOGNIZE_CARERS))
- pet.carers().reinforce(player.getUniqueId(), gain, now, 60_000);
+ pet.carers().reinforce(characterId(player), gain, now, 60_000);
}
public boolean followingAllowed(Pet pet, Player owner) {
- return owner != null && owner.isOnline() && Double.isFinite(distance(owner, pet));
+ return owns(pet, owner) && owner.isOnline() && Double.isFinite(distance(owner, pet));
+ }
+
+ public boolean owns(Pet pet, Player player) {
+ return pet != null && net.tfminecraft.companionpets.ownership.CharacterAccess.owns(pet, player);
+ }
+ public Player owner(Pet pet) {
+ Player player = pet == null ? null : Bukkit.getPlayer(pet.ownerId());
+ return player != null && owns(pet, player) ? player : null;
+ }
+ public UUID characterId(Player player) {
+ var session = net.tfminecraft.companionpets.ownership.CharacterAccess.capture(player);
+ return session == null ? null : session.character();
+ }
+ public java.util.List owned(Player player) { return store().ofCharacter(characterId(player)); }
+ public Player person(UUID character) { return net.tfminecraft.companionpets.ownership.CharacterAccess.person(character); }
+ public boolean ownsKennel(String key, Player player) {
+ UUID character = characterId(player);
+ return character != null && !store.kennelHeld(key) && character.equals(store.kennelCharacter(key))
+ && player.getUniqueId().equals(store.kennelOwner(key));
}
public Entity entity(Pet pet) {
diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetSocial.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetSocial.java
index 4682220..095c41a 100644
--- a/src/main/java/net/tfminecraft/companionpets/runtime/PetSocial.java
+++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetSocial.java
@@ -106,7 +106,7 @@ private void refreshVisits(long now) {
}
boolean calm(Player player, Pet pet) {
- if (!pet.ownerId().equals(player.getUniqueId()) && !player.hasPermission("companionpets.test")) return false;
+ if (!runtime.owns(pet, player) && !player.hasPermission("companionpets.test")) return false;
for (var entry : List.copyOf(active.entrySet())) {
Encounter e = entry.getValue();
if (e.phase != Phase.BARK || e.a != pet && e.b != pet) continue;
@@ -135,7 +135,7 @@ void clear() {
boolean trigger(Player owner, Pet pet, String kind, long now) {
Mob body = body(pet);
if (!runtime.config().social().enabled() || body == null || !available(pet)
- || !pet.ownerId().equals(owner.getUniqueId())) return false;
+ || !runtime.owns(pet, owner)) return false;
Pet other = null;
Mob otherBody = null;
double best = square("greeting".equals(kind) ? runtime.config().social().greetingRadius()
@@ -164,7 +164,7 @@ private boolean begin(Pair pair, Pet a, Pet b, Mob bodyA, Mob bodyB, long now, S
boolean territorial = a.personality() == PetPersonality.TERRITORIAL && b.personality() == PetPersonality.TERRITORIAL;
String choice = forced;
if (choice == null) {
- double barkChance = (a.ownerId().equals(b.ownerId()) ? settings.sameOwnerBarkChance()
+ double barkChance = (java.util.Objects.equals(a.ownerCharacterId(), b.ownerCharacterId()) ? settings.sameOwnerBarkChance()
: settings.territorialBarkChance()) * (1 - 0.8 * friendship(a, b) / 100);
if (territorial && friendship(a, b) < RelationshipMemory.FAMILIAR_AT && both(a, b, PetBehavior.SOCIAL_PROTEST)
&& bodyA.getLocation().distanceSquared(bodyB.getLocation()) <= square(settings.barkRadius())
@@ -338,10 +338,11 @@ private void growl(Mob body) {
}
private boolean ownersNearby(Pet a, Mob bodyA, Pet b, Mob bodyB) {
- return ownersNearby(Bukkit.getPlayer(a.ownerId()), bodyA, Bukkit.getPlayer(b.ownerId()), bodyB);
+ return ownersNearby(runtime.owner(a), bodyA, runtime.owner(b), bodyB);
}
private boolean ownersNearby(Encounter e, Mob a, Mob b) {
return e.a.ownerId().equals(e.ownerIdA) && e.b.ownerId().equals(e.ownerIdB)
+ && (e.ownerA == null || runtime.owns(e.a, e.ownerA)) && (e.ownerB == null || runtime.owns(e.b, e.ownerB))
&& ownersNearby(e.ownerA, a, e.ownerB, b);
}
private boolean ownersNearby(Player a, Mob bodyA, Player b, Mob bodyB) {
@@ -351,7 +352,7 @@ private boolean ownersNearby(Player a, Mob bodyA, Player b, Mob bodyB) {
|| bNear && a == null && near(b, bodyA, radius);
}
private boolean ownersStationary(Pet a, Pet b, long now) {
- Player first = Bukkit.getPlayer(a.ownerId()), second = Bukkit.getPlayer(b.ownerId());
+ Player first = runtime.owner(a), second = runtime.owner(b);
return (first == null || roaming.ownerStationary(first, now)) && (second == null || roaming.ownerStationary(second, now));
}
private static boolean near(Player owner, Mob body, double radius) {
@@ -422,7 +423,7 @@ private final class Encounter {
Encounter(Pet a, Pet b, Phase phase, long now, boolean automatic, PetMeetingMood moodA, PetMeetingMood moodB) {
this.a = a; this.b = b; this.phase = phase; startedAt = now; this.automatic = automatic;
this.moodA = moodA; this.moodB = moodB;
- ownerA = Bukkit.getPlayer(a.ownerId()); ownerB = Bukkit.getPlayer(b.ownerId());
+ ownerA = runtime.owner(a); ownerB = runtime.owner(b);
ownerIdA = a.ownerId(); ownerIdB = b.ownerId();
startA = body(a).getLocation(); startB = body(b).getLocation(); nextFxAt = now + 1500;
}
diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetTicker.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetTicker.java
index 2db46cc..2538b25 100644
--- a/src/main/java/net/tfminecraft/companionpets/runtime/PetTicker.java
+++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetTicker.java
@@ -81,7 +81,7 @@ private void care(long now, long elapsed) {
forgetMissingBody(pet.id());
continue;
}
- Player owner = Bukkit.getPlayer(pet.ownerId());
+ Player owner = runtime.owner(pet);
boolean online = owner != null && owner.isOnline();
// Pet House pets with frozen care (owner offline, or no decay while stored) have nothing to update.
if (pet.stored() && PresenceRules.resolve(true, online, 0, 0,
@@ -254,7 +254,7 @@ private void move(long now) {
net.tfminecraft.companionpets.integration.PetMotion.hold(mob);
continue;
}
- Player owner = Bukkit.getPlayer(pet.ownerId());
+ Player owner = runtime.owner(pet);
watchFetchMotion(pet, mob, now);
if (WaterEscape.needed(mob)) {
if (pet.fetch() == null && pet.activity() != Activity.ATTENDING) {
@@ -322,7 +322,7 @@ private void move(long now) {
}
if (mode == Locomotion.Mode.PLAY)
PlayNavigationGoal.ensure(runtime, pet, mob,
- () -> stepMode(pet, mob, Bukkit.getPlayer(pet.ownerId()), Locomotion.Mode.PLAY, System.currentTimeMillis())).tick();
+ () -> stepMode(pet, mob, runtime.owner(pet), Locomotion.Mode.PLAY, System.currentTimeMillis())).tick();
else if (mode != Locomotion.Mode.FOLLOW) stepMode(pet, mob, owner, mode, now);
express(pet, mob, owner, mode, now);
actions.moments().tick(pet, mob, owner, mode, now);
diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetToyAnticipation.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetToyAnticipation.java
index 567352c..31f44d3 100644
--- a/src/main/java/net/tfminecraft/companionpets/runtime/PetToyAnticipation.java
+++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetToyAnticipation.java
@@ -58,15 +58,16 @@ void tick(long now) {
invitations.entrySet().removeIf(e -> e.getValue() != 0 && now >= e.getValue());
Set candidates = new HashSet<>();
for (Pet pet : runtime.store().active()) {
- Player owner = Bukkit.getPlayer(pet.ownerId());
+ Player owner = runtime.owner(pet);
if (!(runtime.entity(pet) instanceof Mob body) || !ready(pet, body, owner, now)) {
breakFocus(pet, now); continue;
}
Focus current = active.get(pet.id());
+ if (current != null && !net.tfminecraft.companionpets.ownership.CharacterAccess.current(current.session)) { breakFocus(pet, now); current = null; }
Player player = owner;
ItemRef toy = inRange(body, owner, current) ? heldToy(pet, hands.get(owner.getUniqueId())) : null;
if (toy == null) {
- if (current != null && !pet.ownerId().equals(current.player.getUniqueId())
+ if (current != null && !runtime.owns(pet, current.player)
&& !strangerAllowed(pet, current.player)) breakFocus(pet, now);
current = active.get(pet.id());
player = null;
@@ -74,7 +75,7 @@ void tick(long now) {
double bestDistance = Double.POSITIVE_INFINITY;
boolean bestFavorite = false;
for (Player carrier : carriers.getOrDefault(body.getWorld(), List.of())) {
- if (carrier.getUniqueId().equals(pet.ownerId()) || !inRange(body, carrier, current)
+ if (runtime.owns(pet, carrier) || !inRange(body, carrier, current)
|| !strangerAllowed(pet, carrier)) continue;
ItemRef offered = heldToy(pet, hands.get(carrier.getUniqueId()));
if (offered == null) continue;
@@ -144,7 +145,7 @@ void tick(long now) {
boolean active(Pet pet) { return active.containsKey(pet.id()) && pet.fetch() == null; }
boolean attentive(Pet pet, Player player, long now) {
- if (!pet.ownerId().equals(player.getUniqueId()) && !strangerAllowed(pet, player)) return false;
+ if (!runtime.owns(pet, player) && !strangerAllowed(pet, player)) return false;
Focus focus = active.get(pet.id());
RecentFocus previous = recent.get(pet.id());
return focus != null ? focus.player.getUniqueId().equals(player.getUniqueId())
@@ -158,7 +159,7 @@ int slot(Pet pet, Player player) {
void ownerCommanded(Pet pet, long now) {
ownerCommands.put(pet.id(), now + OWNER_COMMAND_LOCKOUT);
breakFocus(pet, now);
- if (pet.fetch() != null && !pet.ownerId().equals(pet.fetch().throwerId()))
+ if (pet.fetch() != null && !runtime.owns(pet, pet.fetch().thrower()))
actions.releaseFetch(pet, null, false);
}
@@ -167,7 +168,7 @@ private void breakFocus(Pet pet, long now) {
}
boolean strangerAllowed(Pet pet, Player player) {
- Player owner = Bukkit.getPlayer(pet.ownerId());
+ Player owner = runtime.owner(pet);
return owner != null && owner.isOnline() && player.isOnline() && owner.getWorld().equals(player.getWorld())
&& owner.getLocation().distanceSquared(player.getLocation()) <= STRANGER_LEASH * STRANGER_LEASH
&& runtime.followingAllowed(pet, owner);
@@ -178,13 +179,14 @@ private static boolean inRange(Mob body, Player player, Focus current) {
return body.getWorld().equals(player.getWorld()) && body.getLocation().distanceSquared(player.getLocation()) <= range * range;
}
- private static ToyInvitation invitation(Pet pet, Player player, ItemRef toy) {
- return ToyInvitation.of(pet, pet.carers().trust(player.getUniqueId()), toy.key().equals(pet.favoriteToy()));
+ private ToyInvitation invitation(Pet pet, Player player, ItemRef toy) {
+ return ToyInvitation.of(pet, pet.carers().trust(runtime.characterId(player)), toy.key().equals(pet.favoriteToy()));
}
void advance(Pet pet, long now) {
Focus focus = active.get(pet.id());
if (focus == null) return;
Player player = focus.player;
+ if (!net.tfminecraft.companionpets.ownership.CharacterAccess.current(focus.session)) { cancel(pet); return; }
if (pet.fetch() != null) return;
// The navigation goal advances every AI tick; the player's hands are rechecked a few times a second.
if (now >= focus.handsCheckedAt + HANDS_MILLIS || now < focus.handsCheckedAt)
@@ -198,7 +200,7 @@ void advance(Pet pet, long now) {
body.setAware(true);
PetFx.sit(body, false); PetFx.lie(body, false);
PetFx.look(body, player.getEyeLocation().subtract(0, 0.55, 0));
- boolean exuberant = pet.ownerId().equals(player.getUniqueId()) || focus.invitation.exuberant();
+ boolean exuberant = runtime.owns(pet, player) || focus.invitation.exuberant();
boolean favorite = exuberant && toy.key().equals(pet.favoriteToy());
if (favorite && !focus.favorite) focus.reactionPending = true;
focus.favorite = favorite;
@@ -219,7 +221,7 @@ void advance(Pet pet, long now) {
}
pet.toyExcitedUntilMillis(favorite ? focus.reactionUntil : 0);
boolean reacting = favorite && now < focus.reactionUntil;
- var feeling = GreetingMood.of(pet, pet.ownerId().equals(player.getUniqueId()) ? pet.bond() : pet.carers().trust(player.getUniqueId()));
+ var feeling = GreetingMood.of(pet, runtime.owns(pet, player) ? pet.bond() : pet.carers().trust(runtime.characterId(player)));
if (reacting && runtime.behaves(pet, PetBehavior.TOY_VOCALIZING) && focus.sounds < 2 && now >= focus.nextSoundAt
&& now >= nextPlayerVoice.getOrDefault(player.getUniqueId(), 0L)) {
runtime.voice().play(body, net.tfminecraft.companionpets.config.PetSounds.Event.TOY,
@@ -248,13 +250,13 @@ private boolean followToy(Pet pet, Focus focus, Player player, long now) {
if (now >= focus.nextMoveAt) {
focus.playerMoving = playerMoving;
focus.playerAt = playerAt;
- double extraDistance = pet.ownerId().equals(player.getUniqueId()) ? 0 : focus.invitation.extraDistance();
+ double extraDistance = runtime.owns(pet, player) ? 0 : focus.invitation.extraDistance();
focus.front = PetSpacing.toyFront(runtime, pet, player, slot(pet, player), extraDistance);
}
Location front = focus.front;
boolean approachingPlayer = front == null || horizontalDistance(body.getLocation(), front) > 1
|| Math.abs(body.getLocation().getY() - front.getY()) > 1;
- boolean wiggle = (pet.ownerId().equals(player.getUniqueId()) || focus.invitation.exuberant()) && runtime.behaves(pet, PetBehavior.TOY_WIGGLE);
+ boolean wiggle = (runtime.owns(pet, player) || focus.invitation.exuberant()) && runtime.behaves(pet, PetBehavior.TOY_WIGGLE);
if (wiggle && front != null && !approachingPlayer && !focus.playerMoving && now >= focus.nextWiggleAt) {
focus.wiggleStartedAt = now;
focus.wiggleUntil = now + 2600;
@@ -305,7 +307,7 @@ private boolean interested(Pet pet, Player player, ItemRef toy, long now) {
: runtime.config().play().toyAttentionSeconds();
// Retain the timestamp after releasing focus so the next behavior tick
// cannot immediately reacquire the same boring toy.
- if (!pet.ownerId().equals(player.getUniqueId())) seconds *= invitation(pet, player, toy).attentionFactor();
+ if (!runtime.owns(pet, player)) seconds *= invitation(pet, player, toy).attentionFactor();
return now - interest.lastStimulusAt < seconds * 1000;
}
@@ -399,6 +401,7 @@ private static final class Focus {
boolean favorite, reactionPending;
long reactionUntil;
int sounds, jumps, slot;
- Focus(Mob body, String toy, Player player) { this.body = body; this.toy = toy; this.player = player; }
+ private final net.tfminecraft.companionpets.ownership.OwnerSession session;
+ Focus(Mob body, String toy, Player player) { this.body = body; this.toy = toy; this.player = player; this.session = net.tfminecraft.companionpets.ownership.CharacterAccess.capture(player); }
}
}
diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetTraining.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetTraining.java
index 5830bd2..2387d5b 100644
--- a/src/main/java/net/tfminecraft/companionpets/runtime/PetTraining.java
+++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetTraining.java
@@ -50,7 +50,7 @@ interface TrickExecutor { void perform(Player player, Pet pet, Entity entity, Tr
}
private void comfort(Pet pet, long now) { pet.nextCryAtMillis(now + Math.round(runtime.config().cryIntervalSeconds() * 1000)); }
public void bindTrick(Player player, Pet pet, String word, Trick trick) {
- if (!pet.ownerId().equals(player.getUniqueId())) return;
+ if (!runtime.owns(pet, player)) return;
if (!checkTrick(player, pet, trick)) return;
pet.bindWord(word, trick);
TrainingSession session = runtime.sessions().training(player.getUniqueId());
@@ -77,7 +77,7 @@ public void bindTrick(Player player, Pet pet, String word, Trick trick) {
}
void handleTrainingChat(Player player, String text, long now, Pet pet, Entity looked) {
- if (pet == null || !pet.ownerId().equals(player.getUniqueId()) || pet.stored()) {
+ if (pet == null || !runtime.owns(pet, player) || pet.stored()) {
return;
}
String line = SpokenOrder.key(text);
@@ -207,7 +207,7 @@ public String treatName(Pet pet) {
}
void beginTraining(Player player, Pet pet, Entity entity) {
- if (!pet.ownerId().equals(player.getUniqueId()) || pet.stored()) return;
+ if (!runtime.owns(pet, player) || pet.stored()) return;
PetTypeDef trainingType = runtime.config().type(pet.typeId());
if (trainingType == null || trainingType.tricks().stream().noneMatch(t -> allowsTrick(pet, t))) {
PetFx.bar(player, pet.name() + " has no available tricks to learn");
@@ -274,7 +274,7 @@ private boolean spendTrainingEffort(Player player, Pet pet) {
}
void reward(Player player, Pet pet, TrainingSession session, ItemStack hand) {
- if (!pet.ownerId().equals(player.getUniqueId()) || !session.petId().equals(pet.id())) return;
+ if (!runtime.owns(pet, player) || !session.petId().equals(pet.id())) return;
Trick trick = session.rewardTrick();
if (!checkTrick(player, pet, trick)) {
session.clearReward();
diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PostureNavigationGoal.java b/src/main/java/net/tfminecraft/companionpets/runtime/PostureNavigationGoal.java
index e2a6b8f..2d255c8 100644
--- a/src/main/java/net/tfminecraft/companionpets/runtime/PostureNavigationGoal.java
+++ b/src/main/java/net/tfminecraft/companionpets/runtime/PostureNavigationGoal.java
@@ -46,7 +46,7 @@ private Locomotion.Mode mode() {
System.currentTimeMillis() < pet.forcedSitUntilMillis(), pet.order(), pet.staying());
// Like the ticker, an idle pet that may not follow its owner right now stays where it is.
if (mode == Locomotion.Mode.FOLLOW && pet.activity() == Activity.NONE
- && !runtime.followingAllowed(pet, Bukkit.getPlayer(pet.ownerId()))) return Locomotion.Mode.STAY;
+ && !runtime.followingAllowed(pet, runtime.owner(pet))) return Locomotion.Mode.STAY;
return mode;
}
diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/TrainingNavigationGoal.java b/src/main/java/net/tfminecraft/companionpets/runtime/TrainingNavigationGoal.java
index fbee389..17c4676 100644
--- a/src/main/java/net/tfminecraft/companionpets/runtime/TrainingNavigationGoal.java
+++ b/src/main/java/net/tfminecraft/companionpets/runtime/TrainingNavigationGoal.java
@@ -62,7 +62,7 @@ static boolean focused(PetRuntime runtime, Pet pet, Mob body, Player trainer) {
if (!body.isValid() || body.isDead() || pet.stored() || pet.dead() || pet.fetch() != null
|| pet.activity() == Activity.SLEEPING || pet.activity() == Activity.ATTENDING
|| WaterEscape.needed(body) || trainer == null || !trainer.isOnline()
- || !pet.ownerId().equals(trainer.getUniqueId()) || !body.getWorld().equals(trainer.getWorld())) return false;
+ || !runtime.owns(pet, trainer) || !body.getWorld().equals(trainer.getWorld())) return false;
var session = runtime.sessions().training(trainer.getUniqueId());
double range = runtime.config().training().sessionDistance();
return session != null && session.petId().equals(pet.id())
diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/WaterNavigationGoal.java b/src/main/java/net/tfminecraft/companionpets/runtime/WaterNavigationGoal.java
index 4ce97d9..dccd47c 100644
--- a/src/main/java/net/tfminecraft/companionpets/runtime/WaterNavigationGoal.java
+++ b/src/main/java/net/tfminecraft/companionpets/runtime/WaterNavigationGoal.java
@@ -38,7 +38,7 @@ static void ensure(PetRuntime runtime, Pet pet, Mob body) {
&& !pet.stored() && !pet.dead() && WaterEscape.needed(body)
&& pet.fetch() == null && pet.activity() != Activity.ATTENDING
&& !(pet.order() == PetOrder.FOLLOW && !pet.staying()
- && runtime.followingAllowed(pet, Bukkit.getPlayer(pet.ownerId())));
+ && runtime.followingAllowed(pet, runtime.owner(pet)));
}
@Override public boolean shouldStayActive() { return shouldActivate(); }
@Override public void start() { routeAt = 0; tick(); }
diff --git a/src/main/java/net/tfminecraft/companionpets/session/Sessions.java b/src/main/java/net/tfminecraft/companionpets/session/Sessions.java
index 8b34967..9e587f0 100644
--- a/src/main/java/net/tfminecraft/companionpets/session/Sessions.java
+++ b/src/main/java/net/tfminecraft/companionpets/session/Sessions.java
@@ -10,52 +10,82 @@ public final class Sessions {
private final Map releases = new java.util.concurrent.ConcurrentHashMap<>();
private final Map training = new HashMap<>();
private final Map trainingRest = new HashMap<>();
+ private final Map hatchTokens = new java.util.concurrent.ConcurrentHashMap<>();
+ private final Map renameTokens = new java.util.concurrent.ConcurrentHashMap<>();
+ private final Map releaseTokens = new java.util.concurrent.ConcurrentHashMap<>();
+ private final Map trainingTokens = new HashMap<>();
+ private final Map published = new java.util.concurrent.ConcurrentHashMap<>();
+ private boolean characters;
+ public void enforceCharacters() {
+ characters = true;
+ org.bukkit.Bukkit.getOnlinePlayers().forEach(player -> refresh(player.getUniqueId()));
+ }
+ public void refresh(UUID account) {
+ var session = net.tfminecraft.companionpets.ownership.CharacterAccess.capture(org.bukkit.Bukkit.getPlayer(account));
+ if (session == null) published.remove(account); else published.put(account, session);
+ }
+ public net.tfminecraft.companionpets.ownership.OwnerSession token(UUID account) { return published.get(account); }
+ private boolean remember(UUID account, Map tokens) {
+ if (!characters) return true;
+ refresh(account);
+ var token = published.get(account);
+ if (token == null) return false;
+ tokens.put(account, token);
+ return true;
+ }
+ private boolean valid(UUID account, Map tokens) {
+ return !characters || net.tfminecraft.companionpets.ownership.CharacterAccess.current(tokens.get(account));
+ }
+
public HatchPrompt hatch(UUID playerId) {
- return hatches.get(playerId);
+ return valid(playerId, hatchTokens) ? hatches.get(playerId) : null;
}
public void hatch(UUID playerId, HatchPrompt prompt) {
- hatches.put(playerId, prompt);
+ if (remember(playerId, hatchTokens)) hatches.put(playerId, prompt);
}
public void clearHatch(UUID playerId) {
- hatches.remove(playerId);
+ hatches.remove(playerId); hatchTokens.remove(playerId);
}
public RenamePrompt rename(UUID playerId) {
- return renames.get(playerId);
+ return valid(playerId, renameTokens) ? renames.get(playerId) : null;
}
public void rename(UUID playerId, RenamePrompt prompt) {
- renames.put(playerId, prompt);
+ if (remember(playerId, renameTokens)) renames.put(playerId, prompt);
}
public void clearRename(UUID playerId) {
- renames.remove(playerId);
+ renames.remove(playerId); renameTokens.remove(playerId);
}
- public ReleasePrompt release(UUID playerId) { return releases.get(playerId); }
- public void release(UUID playerId, ReleasePrompt prompt) { releases.put(playerId, prompt); }
- public void clearRelease(UUID playerId) { releases.remove(playerId); }
+ public ReleasePrompt release(UUID playerId) { return valid(playerId, releaseTokens) ? releases.get(playerId) : null; }
+ public void release(UUID playerId, ReleasePrompt prompt) { if (remember(playerId, releaseTokens)) releases.put(playerId, prompt); }
+ public void clearRelease(UUID playerId) { releases.remove(playerId); releaseTokens.remove(playerId); }
/** Thread-safe identity snapshot for async chat; prompt contents stay on the main thread. */
public Object privatePrompt(UUID playerId) {
- Object prompt = releases.get(playerId);
- if (prompt == null) prompt = hatches.get(playerId);
- return prompt == null ? renames.get(playerId) : prompt;
+ Object prompt = samePublished(playerId, releaseTokens) ? releases.get(playerId) : null;
+ if (prompt == null && samePublished(playerId, hatchTokens)) prompt = hatches.get(playerId);
+ return prompt == null && samePublished(playerId, renameTokens) ? renames.get(playerId) : prompt;
+ }
+ private boolean samePublished(UUID account, Map tokens) {
+ return !characters || tokens.get(account) != null && tokens.get(account).equals(published.get(account));
}
public TrainingSession training(UUID playerId) {
- return training.get(playerId);
+ return valid(playerId, trainingTokens) ? training.get(playerId) : null;
}
public void training(UUID playerId, TrainingSession session) {
- training.put(playerId, session);
+ if (remember(playerId, trainingTokens)) training.put(playerId, session);
}
public void clearTraining(UUID playerId) {
- training.remove(playerId);
+ training.remove(playerId); trainingTokens.remove(playerId);
}
public boolean resting(UUID petId, long now) {
@@ -79,10 +109,11 @@ public void rest(UUID petId, long until) {
}
public void clearPlayer(UUID playerId) {
- hatches.remove(playerId);
- renames.remove(playerId);
- releases.remove(playerId);
- training.remove(playerId);
+ published.remove(playerId);
+ hatches.remove(playerId); hatchTokens.remove(playerId);
+ renames.remove(playerId); renameTokens.remove(playerId);
+ releases.remove(playerId); releaseTokens.remove(playerId);
+ training.remove(playerId); trainingTokens.remove(playerId);
}
public void clearPet(UUID petId) {
@@ -92,6 +123,7 @@ public void clearPet(UUID petId) {
}
public void clearForReload() {
+ hatchTokens.clear(); renameTokens.clear(); releaseTokens.clear(); trainingTokens.clear();
hatches.clear();
renames.clear();
releases.clear();
diff --git a/src/main/java/net/tfminecraft/companionpets/staff/PetSearch.java b/src/main/java/net/tfminecraft/companionpets/staff/PetSearch.java
index 6666df2..c49cf42 100644
--- a/src/main/java/net/tfminecraft/companionpets/staff/PetSearch.java
+++ b/src/main/java/net/tfminecraft/companionpets/staff/PetSearch.java
@@ -21,7 +21,7 @@ static List find(PetRuntime runtime, UUID owner, UUID selected, String n
String filter = name.toLowerCase(Locale.ROOT);
List result = new ArrayList<>();
for (Pet pet : runtime.store().all()) {
- if (selected != null && !selected.equals(pet.id()) || owner != null && !owner.equals(pet.ownerId())
+ if (selected != null && !selected.equals(pet.id()) || owner != null && !owner.equals(pet.ownerCharacterId())
|| !pet.name().toLowerCase(Locale.ROOT).contains(filter)) continue;
var found = bodies.getOrDefault(pet.id(), List.of());
String status;
diff --git a/src/main/java/net/tfminecraft/companionpets/staff/StaffAudit.java b/src/main/java/net/tfminecraft/companionpets/staff/StaffAudit.java
index 33bf526..45d51fd 100644
--- a/src/main/java/net/tfminecraft/companionpets/staff/StaffAudit.java
+++ b/src/main/java/net/tfminecraft/companionpets/staff/StaffAudit.java
@@ -41,7 +41,7 @@ public boolean append(CommandSender actor, String action, Pet pet, String before
}
public static String snapshot(Pet pet) {
- return "owner=" + pet.ownerId() + ";type=" + pet.typeId() + ";name=" + pet.name()
+ return "owner=" + pet.ownerId() + ";character=" + pet.ownerCharacterId() + ";type=" + pet.typeId() + ";name=" + pet.name()
+ ";stored=" + pet.stored() + ";entity=" + pet.entityId() + ";illness=" + pet.illness()
+ ";treated=" + pet.treated() + ";order=" + pet.order() + ";activity=" + pet.activity()
+ ";needs=" + java.util.Arrays.stream(Need.values()).map(n -> n + "=" + pet.need(n)).toList()
diff --git a/src/main/java/net/tfminecraft/companionpets/staff/StaffCommands.java b/src/main/java/net/tfminecraft/companionpets/staff/StaffCommands.java
index dec6fec..71b6b4d 100644
--- a/src/main/java/net/tfminecraft/companionpets/staff/StaffCommands.java
+++ b/src/main/java/net/tfminecraft/companionpets/staff/StaffCommands.java
@@ -57,27 +57,22 @@ public boolean execute(CommandSender sender, String... args) {
}
public UUID resolveOwner(String token) {
- Player online = Bukkit.getPlayerExact(token);
- if (online != null) return online.getUniqueId();
- OfflinePlayer cached = Bukkit.getOfflinePlayerIfCached(token);
- if (cached != null) return cached.getUniqueId();
- // Explicit UUIDs remain available only for restoring unknown/offline owners with create.
- try { return UUID.fromString(token); } catch (IllegalArgumentException ignored) { }
- throw new IllegalArgumentException("Unknown player '" + token + "'. Use Tab to select a player.");
+ UUID character = net.tfminecraft.companionpets.ownership.CharacterAccess.resolve(token);
+ require(character != null, "Unknown or ambiguous character '" + token + "'. Specify a known character UUID or unique living character name.");
+ return character;
}
public String ownerName(UUID owner) {
- var player = Bukkit.getOfflinePlayer(owner);
- return player.getName() == null ? "Unknown player" : player.getName();
+ return net.tfminecraft.companionpets.ownership.CharacterAccess.name(owner);
}
public List owners() {
- return runtime.store().all().stream().map(Pet::ownerId).distinct()
+ return runtime.store().all().stream().map(Pet::ownerCharacterId).filter(java.util.Objects::nonNull).distinct()
.sorted(Comparator.comparing(this::ownerName, String.CASE_INSENSITIVE_ORDER)).toList();
}
public List owned(UUID owner) {
- return runtime.store().of(owner).stream().sorted(Comparator.comparing((Pet p) -> p.name(), String.CASE_INSENSITIVE_ORDER).thenComparing(Pet::id)).toList();
+ return runtime.store().ofCharacter(owner).stream().sorted(Comparator.comparing((Pet p) -> p.name(), String.CASE_INSENSITIVE_ORDER).thenComparing(Pet::id)).toList();
}
public Pet resolveNamedPet(String player, String name) {
@@ -92,6 +87,12 @@ private void list(CommandSender sender, String[] args) {
sender.sendMessage("Usage: /companionpets list [pet name]. Use Tab to select a player.");
return;
}
+ if (args[1].equalsIgnoreCase("unassigned")) {
+ var held = runtime.store().unassigned();
+ sender.sendMessage("Unassigned or held pet records: " + held.size());
+ held.forEach(sender::sendMessage);
+ return;
+ }
UUID owner = resolveOwner(args[1]);
if (args.length > 2) {
Pet pet = resolveNamedPet(args[1], String.join(" ", Arrays.copyOfRange(args, 2, args.length)));
@@ -123,7 +124,7 @@ private void find(CommandSender sender, String[] args) {
}
public void info(CommandSender sender, Pet pet) {
- sender.sendMessage(pet.name() + " (" + pet.typeId() + "), owner: " + ownerName(pet.ownerId()));
+ sender.sendMessage(pet.name() + " (" + pet.typeId() + "), owner: " + ownerName(pet.ownerCharacterId()));
sender.sendMessage("State: " + (pet.stored() ? "in Pet House" : "outside") + "; order: " + pet.order() + "; illness: " + pet.illness());
sender.sendMessage("Needs: " + Arrays.stream(Need.values()).map(n -> n.name().toLowerCase(Locale.ROOT) + "=" + Math.round(pet.need(n))).toList());
sender.sendMessage("Tricks: " + pet.words().keySet());
@@ -133,8 +134,12 @@ private void create(CommandSender sender, String[] args) {
require(args.length >= 3, PetCreation.usage());
require(runtime.store().canRestoreBodies(), "Pet persistence is unavailable; creation refused.");
UUID owner = resolveOwner(args[1]);
- Pet pet = PetCreation.parse(runtime, owner, args);
- require(runtime.store().countPets(owner) < runtime.config().limits().maxPets(), "The player already has the maximum number of pets.");
+ require(net.tfminecraft.companionpets.ownership.CharacterAccess.alive(owner), "Creation requires a living character.");
+ UUID account = net.tfminecraft.companionpets.ownership.CharacterAccess.account(owner);
+ require(account != null, "The character owner is unavailable.");
+ Pet pet = PetCreation.parse(runtime, account, args);
+ pet.ownership(account, owner);
+ require(runtime.store().countPets(account) < runtime.config().limits().maxPets(), "The player already has the maximum number of pets.");
if (!audit.append(sender, "create-requested", pet, "none", StaffAudit.snapshot(pet))) return;
runtime.store().add(pet);
if (!runtime.store().save()) {
diff --git a/src/main/java/net/tfminecraft/companionpets/staff/StaffMenus.java b/src/main/java/net/tfminecraft/companionpets/staff/StaffMenus.java
index a4b86ed..c554d2b 100644
--- a/src/main/java/net/tfminecraft/companionpets/staff/StaffMenus.java
+++ b/src/main/java/net/tfminecraft/companionpets/staff/StaffMenus.java
@@ -41,7 +41,7 @@ public void inspect(Player player, Pet pet) {
if (!allowed(player, "list")) return;
var holder = create(player, StaffMenuHolder.Kind.INSPECT, pet.id(), 0, pet.name(), 45);
new net.tfminecraft.companionpets.gui.PetMenus(runtime).fillInformation(holder.getInventory(), pet);
- holder.button(net.tfminecraft.companionpets.gui.PetMenus.BACK_SLOT, () -> list(player, pet.ownerId(), 0));
+ holder.button(net.tfminecraft.companionpets.gui.PetMenus.BACK_SLOT, () -> list(player, pet.ownerCharacterId(), 0));
holder.button(net.tfminecraft.companionpets.gui.PetMenus.TRICKS_SLOT, () -> tricks(player, pet.id(), 0));
player.openInventory(holder.getInventory());
}
diff --git a/src/main/java/net/tfminecraft/companionpets/staff/TestCommands.java b/src/main/java/net/tfminecraft/companionpets/staff/TestCommands.java
index f2278d7..18734f7 100644
--- a/src/main/java/net/tfminecraft/companionpets/staff/TestCommands.java
+++ b/src/main/java/net/tfminecraft/companionpets/staff/TestCommands.java
@@ -34,7 +34,7 @@ public boolean execute(CommandSender sender, String... args) {
StaffCommands.require(args.length == 2 && List.of("affection", "bark", "mischief", "dig", "belly", "greeting", "pet-greeting").contains(args[1].toLowerCase(Locale.ROOT)),
"Usage: /companionpets moment , while looking at your pet.");
Pet pet = runtime.byEntity(PetActions.lookingAt(player, 6));
- StaffCommands.require(pet != null && pet.ownerId().equals(player.getUniqueId()), "Look at one of your pets first.");
+ StaffCommands.require(pet != null && runtime.owns(pet, player), "Look at one of your pets first.");
if (!audit.append(sender, "moment-requested", pet, StaffAudit.snapshot(pet), args[1])) return true;
if (actions.triggerTestMoment(player, args[1])) audit.append(sender, "moment", pet, "requested", StaffAudit.snapshot(pet));
}
diff --git a/src/main/java/net/tfminecraft/companionpets/store/PetStore.java b/src/main/java/net/tfminecraft/companionpets/store/PetStore.java
index 7335361..cae6680 100644
--- a/src/main/java/net/tfminecraft/companionpets/store/PetStore.java
+++ b/src/main/java/net/tfminecraft/companionpets/store/PetStore.java
@@ -55,14 +55,23 @@ public final class PetStore {
private final Map pets = new LinkedHashMap<>();
private record CachedRow(long revision, Map values) { }
private final Map cachedRows = new HashMap<>();
+ private final Map> readCarerIds = new HashMap<>();
+ private final Map> readFriendIds = new HashMap<>();
private Index index;
private final Runnable invalidate = () -> index = null;
private final Map kennels = new LinkedHashMap<>();
+ private final Map kennelCharacters = new LinkedHashMap<>();
+ private final Set heldKennels = new LinkedHashSet<>();
+ private Map rawDocument = new LinkedHashMap<>();
private boolean loaded;
+ private boolean ownershipReady;
private boolean deletionLogHealthy;
private boolean sessionStarted;
private final AtomicBoolean dirty = new AtomicBoolean();
private ExecutorService writer;
+ private volatile String diskHash;
+ private volatile boolean persistenceConflict;
+ public boolean persistenceConflict() { return persistenceConflict; }
public PetStore(JavaPlugin plugin) {
this(new File(plugin.getDataFolder(), "pets.yml"), plugin.getLogger());
@@ -79,6 +88,7 @@ public PetStore(JavaPlugin plugin) {
public boolean load() {
cachedRows.clear();
loaded = false;
+ ownershipReady = false;
deletionLogHealthy = false;
if (session.exists()) {
logger.severe("Previous pet session did not commit a clean shutdown. Stop the server and reconcile pets.yml "
@@ -122,17 +132,32 @@ private boolean read(File source) {
YamlConfiguration yaml = new YamlConfiguration();
try {
yaml.load(source);
+ persistenceConflict = false;
+ diskHash = net.tfminecraft.companionpets.ownership.OwnershipMigration.hash(Files.readString(source.toPath()));
+ rawDocument = net.tfminecraft.companionpets.ownership.OwnershipMigration.raw(Files.readString(source.toPath()));
if (!yaml.contains("pets") && !yaml.contains("kennels")) {
throw new IllegalArgumentException("Missing pets and kennels data");
}
+ readCarerIds.clear(); readFriendIds.clear();
Map nextPets = new LinkedHashMap<>();
Map nextKennels = new LinkedHashMap<>();
+ Map nextCharacters = new LinkedHashMap<>();
ConfigurationSection petSection = yaml.getConfigurationSection("pets");
if (yaml.contains("pets") && petSection == null) throw new IllegalArgumentException("pets must be a section");
if (petSection != null) {
for (String id : petSection.getKeys(false)) {
Pet pet = readPet(UUID.fromString(id), petSection.getConfigurationSection(id));
if (pet == null) throw new IllegalArgumentException("Invalid pet record " + id);
+ readCarerIds.put(pet.id(), pet.carers().entries().keySet().stream().map(UUID::toString).collect(java.util.stream.Collectors.toSet()));
+ readFriendIds.put(pet.id(), pet.friends().entries().keySet().stream().map(UUID::toString).collect(java.util.stream.Collectors.toSet()));
+ Pet previous = sessionStarted ? pets.get(pet.id()) : null;
+ if (previous != null) {
+ previous.ownership(pet.ownerId(), pet.ownerCharacterId());
+ previous.ownershipHeld(previous.ownershipHeld() || pet.ownershipHeld());
+ previous.carers().clear();
+ pet.carers().entries().forEach(previous.carers()::restore);
+ pet = previous;
+ }
nextPets.put(pet.id(), pet);
}
}
@@ -147,15 +172,20 @@ private boolean read(File source) {
((Number) row.get("x")).intValue(),
((Number) row.get("y")).intValue(),
((Number) row.get("z")).intValue());
- nextKennels.put(key, UUID.fromString(String.valueOf(owner)));
+ if (nextKennels.putIfAbsent(key, UUID.fromString(String.valueOf(owner))) != null)
+ throw new IllegalArgumentException("Duplicate Pet House coordinates: " + key);
+ UUID character = net.tfminecraft.companionpets.ownership.OwnershipMigration.uuid(row.get("owner-character"));
+ if (character != null) nextCharacters.put(key, character);
}
pets.clear();
pets.putAll(nextPets);
deleted.forEach(pets::remove);
- pets.values().forEach(pet -> pet.indexChanged(invalidate));
+ pets.values().forEach(pet -> { pet.indexChanged(invalidate); pet.persistenceHeld(() -> !loaded || !deletionLogHealthy || persistenceConflict); });
index = null;
kennels.clear();
kennels.putAll(nextKennels);
+ kennelCharacters.clear();
+ kennelCharacters.putAll(nextCharacters);
loaded = true;
return true;
} catch (IOException | InvalidConfigurationException | RuntimeException ex) {
@@ -197,13 +227,15 @@ void awaitWrites() throws InterruptedException, ExecutionException {
}
/** Saves now and waits, for callers that must know the record reached the disk. */
- public boolean save() {
+ public boolean save() { return save(false); }
+ public boolean saveOwnership() { return save(true); }
+ private boolean save(boolean ownership) {
Snapshot snapshot = snapshot();
if (snapshot == null) return false;
dirty.set(false);
try {
// The single writer keeps this after any queued background write, so older data never wins.
- if (writer().submit(() -> write(snapshot)).get()) return true;
+ if (writer().submit(() -> write(snapshot, ownership)).get()) return true;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
} catch (ExecutionException | RejectedExecutionException ex) {
@@ -224,7 +256,8 @@ private ExecutorService writer() {
return writer;
}
- record Snapshot(Map> pets, List