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> kennels) { } + record Snapshot(Map> pets, List> kennels, + Map raw) { } Snapshot snapshot() { if (!loaded || !deletionLogHealthy) { @@ -234,7 +267,7 @@ Snapshot snapshot() { for (Pet pet : pets.values()) { CachedRow cached = cachedRows.get(pet.id()); if (cached == null || cached.revision() != pet.revision()) { - cached = new CachedRow(pet.revision(), row(pet)); + cached = new CachedRow(pet.revision(), preservedRow(pet)); cachedRows.put(pet.id(), cached); } rows.put(pet.id().toString(), cached.values()); @@ -256,15 +289,24 @@ Snapshot snapshot() { logger.warning("Skipping kennel with invalid coordinates: " + entry.getKey()); continue; } + for (Object previous : rawDocument.get("kennels") instanceof List previousRows ? previousRows : List.of()) { + if (previous instanceof Map old && String.valueOf(old.get("world")).equals(row.get("world")) + && old.get("x").equals(row.get("x")) && old.get("y").equals(row.get("y")) && old.get("z").equals(row.get("z"))) + old.forEach((key, value) -> row.put(String.valueOf(key), value)); + } row.put("owner", entry.getValue().toString()); + UUID character = kennelCharacters.get(entry.getKey()); + if (character != null) row.put("owner-character", character.toString()); kennelRows.add(Collections.unmodifiableMap(row)); } - return new Snapshot(Collections.unmodifiableMap(rows), List.copyOf(kennelRows)); + return new Snapshot(Collections.unmodifiableMap(rows), List.copyOf(kennelRows), Collections.unmodifiableMap(new LinkedHashMap<>(rawDocument))); } private static Map row(Pet pet) { Map row = new LinkedHashMap<>(); put(row, "owner", pet.ownerId().toString()); + put(row, "owner-character", pet.ownerCharacterId() == null ? null : pet.ownerCharacterId().toString()); + put(row, "carers-characters", true); put(row, "type", pet.typeId()); put(row, "name", pet.name()); put(row, "sex", pet.sex().name()); @@ -322,20 +364,35 @@ private static void put(Map row, String key, Object value) { row.put(key, value); } - boolean write(Snapshot snapshot) { - YamlConfiguration yaml = new YamlConfiguration(); - yaml.set("version", 1); - snapshot.pets().forEach((id, row) -> yaml.createSection("pets." + id, row)); - yaml.set("kennels", snapshot.kennels()); + boolean write(Snapshot snapshot) { return write(snapshot, false); } + private boolean write(Snapshot snapshot, boolean ownership) { + Map document = new LinkedHashMap<>(snapshot.raw()); + Map rows = document.get("pets") instanceof Map existing ? stringMap(existing) : new LinkedHashMap<>(); + deleted.forEach(id -> rows.remove(id.toString())); + rows.putAll(snapshot.pets()); + document.put("version", 1); + if (ownershipReady) document.put("identity-version", 2); + document.put("pets", rows); + document.put("kennels", snapshot.kennels()); File temp = new File(file.getParentFile(), file.getName() + ".tmp"); try { - yaml.save(temp); + if (persistenceConflict || file.exists() != (diskHash != null) + || file.exists() && !diskHash.equals(net.tfminecraft.companionpets.ownership.OwnershipMigration.hash(Files.readString(file.toPath())))) { + persistenceConflict = true; + throw new IOException("BLOCKED: pets.yml changed outside its current writer; preserve it for review"); + } + Files.createDirectories(temp.toPath().toAbsolutePath().getParent()); + Files.writeString(temp.toPath(), net.tfminecraft.companionpets.ownership.OwnershipMigration.dump(document)); if (file.exists()) { File backupTemp = new File(file.getParentFile(), backup.getName() + ".tmp"); Files.copy(file.toPath(), backupTemp.toPath(), StandardCopyOption.REPLACE_EXISTING); replace(backupTemp, backup); } - replace(temp, file); + force(temp.toPath()); + if (ownership) Files.move(temp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + else replace(temp, file); + force(file.toPath().toAbsolutePath().getParent()); + diskHash = net.tfminecraft.companionpets.ownership.OwnershipMigration.hash(Files.readString(file.toPath())); if (!backup.exists()) { File backupTemp = new File(file.getParentFile(), backup.getName() + ".tmp"); Files.copy(file.toPath(), backupTemp.toPath(), StandardCopyOption.REPLACE_EXISTING); @@ -394,6 +451,13 @@ static void writeAndForce(FileChannel channel, String value) throws IOException channel.force(true); } + public boolean hasUnassignedOwner(UUID account) { + return of(account).stream().anyMatch(pet -> pet.ownerCharacterId() == null) + || kennels.entrySet().stream().anyMatch(entry -> account.equals(entry.getValue()) && !kennelCharacters.containsKey(entry.getKey())); + } + + public boolean ownershipReady() { return ownershipReady && canRestoreBodies() && !persistenceConflict; } + public boolean canRestoreBodies() { return loaded && deletionLogHealthy; } @@ -420,10 +484,12 @@ private static Pet readPet(UUID id, ConfigurationSection section) { section.getString("type"), section.getString("name", "Mascota"), enumValue(PetSex.class, section.getString("sex"), PetSex.FEMALE)); + pet.ownership(pet.ownerId(), net.tfminecraft.companionpets.ownership.OwnershipMigration.uuid(section.get("owner-character"))); + pet.ownershipHeld(section.contains("owner-character") && pet.ownerCharacterId() == null); pet.bornAt(section.getLong("born-at", 0L)); pet.lastOwnerNearbyMillis(section.getLong("last-owner-nearby-millis", 0L)); pet.lastGreetingMillis(section.getLong("last-greeting-millis", 0L)); - readMemories(section.getConfigurationSection("carers"), pet.carers()); + if (section.getBoolean("carers-characters")) readMemories(section.getConfigurationSection("carers"), pet.carers()); readMemories(section.getConfigurationSection("friends"), pet.friends()); pet.personality(enumValue(PetPersonality.class, section.getString("personality"), PetPersonality.forId(id))); pet.order(enumValue(PetOrder.class, section.getString("order"), PetOrder.FOLLOW)); @@ -561,6 +627,7 @@ public void add(Pet pet) { cachedRows.remove(pet.id()); if (previous != null && previous != pet) previous.indexChanged(null); pet.indexChanged(invalidate); + pet.persistenceHeld(() -> !loaded || !deletionLogHealthy || persistenceConflict); index = null; } @@ -621,26 +688,125 @@ private Index index() { List active = new ArrayList<>(); Map> owners = new HashMap<>(); Map bodies = new HashMap<>(); + Map> characters = new HashMap<>(); for (Pet pet : all) { if (!pet.stored() && !pet.dead()) active.add(pet); owners.computeIfAbsent(pet.ownerId(), id -> new ArrayList<>()).add(pet); + if (pet.ownerCharacterId() != null) characters.computeIfAbsent(pet.ownerCharacterId(), id -> new ArrayList<>()).add(pet); if (pet.entityId() != null) bodies.putIfAbsent(pet.entityId(), pet); } - index = new Index(all, List.copyOf(active), owners, bodies); + index = new Index(all, List.copyOf(active), owners, bodies, characters); return index; } /** Rebuilt only after a record joins, leaves, changes owner, body, storage or death. */ - private record Index(List all, List active, Map> owners, Map bodies) { } + private record Index(List all, List active, Map> owners, Map bodies, Map> characters) { } public void kennel(String key, UUID ownerId) { kennels.put(key, ownerId); } public boolean removeKennel(String key) { + kennelCharacters.remove(key); return kennels.remove(key) != null; } + public void kennel(String key, UUID account, UUID character) { + kennels.put(key, account); + if (character != null) kennelCharacters.put(key, character); + } + public void holdKennel(String key, boolean held) { if (held) heldKennels.add(key); else heldKennels.remove(key); } + public boolean kennelHeld(String key) { return heldKennels.contains(key); } + public List unassigned() { + List rows = new ArrayList<>(); + all().stream().filter(pet -> pet.ownerCharacterId() == null || pet.ownershipHeld()) + .forEach(pet -> rows.add("Pet " + pet.name() + " [" + pet.id() + "] needs reviewed ownership")); + kennels.forEach((key, account) -> { if (!kennelCharacters.containsKey(key) || heldKennels.contains(key)) rows.add("Pet House " + key + " needs reviewed ownership"); }); + return List.copyOf(rows); + } + public UUID kennelCharacter(String key) { return kennelCharacters.get(key); } + public Map kennelCharacters() { return Map.copyOf(kennelCharacters); } + public List ofCharacter(UUID character) { + return character == null ? List.of() : index().characters().getOrDefault(character, List.of()); + } + public boolean migrate() { + if (!net.tfminecraft.companionpets.ownership.CharacterAccess.heirReady()) return false; + try { + // The readiness retry shares a live store: drain older snapshots and commit current + // gameplay before freezing migration bytes. No new main-thread writer can race this cut. + if (sessionStarted && !saveOwnership()) return false; + new net.tfminecraft.companionpets.ownership.OwnershipMigration(file.toPath(), + net.tfminecraft.companionpets.ownership.CharacterAccess::heir).run(); + cachedRows.clear(); + boolean success = sessionStarted ? read(file) : load(); + ownershipReady = success; + return success; + } catch (IOException ex) { + logger.log(Level.SEVERE, "Pet ownership migration is held; saving disabled", ex); + loaded = false; + return false; + } + } + + private Map preservedRow(Pet pet) { + Object section = rawDocument.get("pets"); + Object old = section instanceof Map all ? all.get(pet.id().toString()) : null; + Map result = old instanceof Map map ? stringMap(map) : new LinkedHashMap<>(); + Map values = new LinkedHashMap<>(row(pet)); + for (String key : List.of("favorite-toy", "carried-toy")) if (!values.containsKey(key)) result.remove(key); + // An unconverted carer page remains inert/raw until reviewed heirs become ready. + boolean legacyCarers = old instanceof Map map && map.containsKey("carers") && !Boolean.TRUE.equals(map.get("carers-characters")); + if (legacyCarers) { values.remove("carers"); values.remove("carers-characters"); } + for (String key : List.of("carers", "friends")) { + if (key.equals("carers") && legacyCarers) continue; + Object previous = result.get(key); + Map next = values.get(key) instanceof Map map ? stringMap(map) : new LinkedHashMap<>(); + Set consumed = (key.equals("carers") ? readCarerIds : readFriendIds).getOrDefault(pet.id(), Set.of()); + if (previous instanceof Map memories) memories.forEach((id, value) -> { + String name = String.valueOf(id); + if (!consumed.contains(name) && !(value instanceof Map)) next.put(name, value); + else if (next.containsKey(name)) next.put(name, merge(value, next.get(name))); + else if (!consumed.contains(name)) next.put(name, value); + }); + if (next.isEmpty()) result.remove(key); else result.put(key, Collections.unmodifiableMap(next)); + values.remove(key); + } + // Unknown trick fields survive; known word rows retain their extra metadata by literal word. + if (result.get("words") instanceof List previous && values.get("words") instanceof List next) { + List words = new ArrayList<>(); + for (Object word : next) { + Object merged = word; + if (word instanceof Map current) for (Object oldWord : previous) + if (oldWord instanceof Map oldRow && java.util.Objects.equals(oldRow.get("word"), current.get("word"))) merged = merge(oldWord, current); + words.add(merged); + } + for (Object oldWord : previous) if (!(oldWord instanceof Map oldRow) || !knownTrick(oldRow.get("trick"))) words.add(oldWord); + values.put("words", List.copyOf(words)); + } + values.forEach((key, value) -> result.put(key, merge(result.get(key), value))); + return Collections.unmodifiableMap(result); + } + private static boolean knownTrick(Object raw) { + try { Trick.valueOf(String.valueOf(raw)); return true; } + catch (IllegalArgumentException ex) { return false; } + } + private static Map stringMap(Map map) { + Map result = new LinkedHashMap<>(); + map.forEach((key, value) -> result.put(String.valueOf(key), value)); + return result; + } + private static Object merge(Object old, Object current) { + if (old instanceof Map previous && current instanceof Map next) { + Map result = stringMap(previous); + next.forEach((key, value) -> result.put(String.valueOf(key), merge(result.get(String.valueOf(key)), value))); + return Collections.unmodifiableMap(result); + } + return current; + } + private static void force(java.nio.file.Path path) throws IOException { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { channel.force(true); } + } + public UUID kennelOwner(String key) { return kennels.get(key); } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 0e7500e..884192c 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -49,4 +49,5 @@ permissions: description: Give configured eggs default: op -softdepend: [MythicMobs, ModelEngine, ItemsAdder, MMOItems, MythicLib] +depend: [TLibs] +softdepend: [RPCharacters, MythicMobs, ModelEngine, ItemsAdder, MMOItems, MythicLib] diff --git a/src/test/java/net/tfminecraft/companionpets/CommandLifecycleTest.java b/src/test/java/net/tfminecraft/companionpets/CommandLifecycleTest.java index c9045f1..d2a158d 100644 --- a/src/test/java/net/tfminecraft/companionpets/CommandLifecycleTest.java +++ b/src/test/java/net/tfminecraft/companionpets/CommandLifecycleTest.java @@ -18,7 +18,7 @@ class CommandLifecycleTest { @BeforeEach void setup() { server = MockBukkit.mock(); server.addSimpleWorld("world"); - plugin = MockBukkit.load(PetsPlugin.class); + plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.load(PetsPlugin.class); player = server.addPlayer(); player.openInventory(server.createInventory(null, 9)); } diff --git a/src/test/java/net/tfminecraft/companionpets/PetsPluginLifecycleTest.java b/src/test/java/net/tfminecraft/companionpets/PetsPluginLifecycleTest.java index a08eccb..8ed2287 100644 --- a/src/test/java/net/tfminecraft/companionpets/PetsPluginLifecycleTest.java +++ b/src/test/java/net/tfminecraft/companionpets/PetsPluginLifecycleTest.java @@ -70,7 +70,8 @@ private T prepare(Class type) { plugin.getLogger().addHandler(logs); return loaded; } - private void enable() { server.getPluginManager().enablePlugin(plugin); assertTrue(plugin.isEnabled()); } + private void enable() { + net.tfminecraft.companionpets.testutil.OwnershipFixture.register(plugin); server.getPluginManager().enablePlugin(plugin); assertTrue(plugin.isEnabled()); } private Path file(String name) { return plugin.getDataFolder().toPath().resolve(name); } private YamlConfiguration config() { return YamlConfiguration.loadConfiguration(file("config.yml").toFile()); } private PetRuntime runtime() throws Exception { @@ -88,7 +89,7 @@ private String reload() { assertTrue(plugin.onCommand(console, null, "companionpets", new String[]{"reload"})); return console.nextMessage(); } - private Pet pet(UUID owner) { return new Pet(UUID.randomUUID(), owner, "wolf", "Toby", PetSex.MALE); } + private Pet pet(UUID owner) { return net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner, "wolf", "Toby", PetSex.MALE); } private void saveBeforeEnable(Pet pet) { var stored = new PetStore(plugin); assertTrue(stored.load()); stored.add(pet); assertTrue(stored.close()); @@ -117,6 +118,18 @@ private WolfMock body(Pet pet, Location at) { return wolf; } + @Test void lateReviewedHeirsMigrateBeforeAuthorityAndKeepExistingLoadedPets() throws Exception { + prepare();var owner=server.addPlayer();var pet=pet(owner.getUniqueId());pet.stored(true);saveBeforeEnable(pet); + var saved=YamlConfiguration.loadConfiguration(file("pets.yml").toFile());saved.set("pets."+pet.id()+".owner-character",null);saved.save(file("pets.yml").toFile()); + var identity=org.mockito.Mockito.mock(net.tfminecraft.tlibs.character.CharacterIdentity.class);var ready=new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.when(identity.legacyHeirsReady()).thenAnswer(call->ready.get()); + org.mockito.Mockito.when(identity.legacyHeir(owner.getUniqueId())).thenReturn(java.util.Optional.of(new net.tfminecraft.tlibs.character.CharacterRef(owner.getUniqueId(),owner.getUniqueId()))); + org.bukkit.Bukkit.getServicesManager().register(net.tfminecraft.tlibs.character.CharacterIdentity.class,identity,plugin,org.bukkit.plugin.ServicePriority.Highest); + enable();var existing=runtime().store().get(pet.id());assertNull(existing.ownerCharacterId());server.getScheduler().performOneTick();assertNull(existing.ownerCharacterId()); + ready.set(true);server.getScheduler().performTicks(20);assertSame(existing,runtime().store().get(pet.id()));assertEquals(owner.getUniqueId(),existing.ownerCharacterId()); + assertTrue(Files.exists(file("ownership-migrations/plan-0001.yml"))); + } + @Test void recoveryGuardDisablesStartupAndPreservesFilesWithoutSchedulingActions() throws Exception { prepare(); Files.writeString(file("pets-recovery-required"), "unclean shutdown evidence\n"); @@ -267,7 +280,7 @@ private WolfMock body(Pet pet, Location at) { } @Test void enabledModelProviderWithUnavailableApiFallsBackWithoutDisablingPets() throws Exception { - MockBukkit.createMockPlugin("ModelEngine"); prepare(); enable(); + net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin("ModelEngine"); prepare(); enable(); assertInstanceOf(IdleVisual.class, runtime().visual()); assertTrue(messages.stream().anyMatch(record -> record.getMessage().contains("ModelEngine 4 integration unavailable"))); assertTrue(Files.exists(file("pets-recovery-required"))); @@ -426,7 +439,7 @@ public enum Action { INTERACT } } @Test void modelStartupRoutesInteractionAndReloadAndShutdownReleaseOwnedRenderers() throws Exception { - MockBukkit.createMockPlugin("ModelEngine"); + net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin("ModelEngine"); var description = new PluginDescriptionFile("CompanionPets", "test", PetsPlugin.class.getName()); var data = Files.createTempDirectory(modelClasses, "pets-data-"); var loader = new ModelPluginLoader(server, description, data); diff --git a/src/test/java/net/tfminecraft/companionpets/PublicContractsCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/PublicContractsCoverageTest.java index bf3d4e5..0c09080 100644 --- a/src/test/java/net/tfminecraft/companionpets/PublicContractsCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/PublicContractsCoverageTest.java @@ -26,9 +26,9 @@ class PublicContractsCoverageTest { JavaPlugin plugin; - @BeforeEach void start() { MockBukkit.mock(); plugin=MockBukkit.createMockPlugin(); } + @BeforeEach void start() { MockBukkit.mock(); plugin=net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); } @AfterEach void stop() { MockBukkit.unmock(); } - Pet pet() { return new Pet(UUID.randomUUID(),UUID.randomUUID(),"dog","Toby",PetSex.MALE); } + Pet pet() { return net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(),UUID.randomUUID(),"dog","Toby",PetSex.MALE); } CompanionConfig config(String yaml) throws Exception { var source=new YamlConfiguration(); source.loadFromString(yaml); return CompanionConfig.load(plugin,source); } diff --git a/src/test/java/net/tfminecraft/companionpets/behavior/GreetingMoodTest.java b/src/test/java/net/tfminecraft/companionpets/behavior/GreetingMoodTest.java index d4ecef2..7793782 100644 --- a/src/test/java/net/tfminecraft/companionpets/behavior/GreetingMoodTest.java +++ b/src/test/java/net/tfminecraft/companionpets/behavior/GreetingMoodTest.java @@ -7,7 +7,7 @@ class GreetingMoodTest { private Pet pet() { - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Toby", PetSex.MALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Toby", PetSex.MALE); pet.personality(PetPersonality.FRIENDLY); return pet; } @Test void relationshipAndEnergyChangeEnthusiasmButIllnessAlwaysWins() { diff --git a/src/test/java/net/tfminecraft/companionpets/behavior/PetMeetingMoodTest.java b/src/test/java/net/tfminecraft/companionpets/behavior/PetMeetingMoodTest.java index 6c57191..72b95ff 100644 --- a/src/test/java/net/tfminecraft/companionpets/behavior/PetMeetingMoodTest.java +++ b/src/test/java/net/tfminecraft/companionpets/behavior/PetMeetingMoodTest.java @@ -7,7 +7,7 @@ class PetMeetingMoodTest { private Pet pet(PetPersonality personality) { - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "dog", "Toby", PetSex.MALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "dog", "Toby", PetSex.MALE); pet.personality(personality); return pet; } @Test void reservedPetsWarmUpToTheirOwnFriendsAndKeepDistinctPersonalities() { diff --git a/src/test/java/net/tfminecraft/companionpets/behavior/ToyInvitationTest.java b/src/test/java/net/tfminecraft/companionpets/behavior/ToyInvitationTest.java index c2aa384..4c7f058 100644 --- a/src/test/java/net/tfminecraft/companionpets/behavior/ToyInvitationTest.java +++ b/src/test/java/net/tfminecraft/companionpets/behavior/ToyInvitationTest.java @@ -11,7 +11,7 @@ class ToyInvitationTest { PetPersonality.SHY, PetPersonality.TERRITORIAL, PetPersonality.GRUMPY}; double[][] table = {{.60, .80, .95}, {.45, .70, .90}, {.10, .40, .85}, {.12, .45, .80}, {.08, .25, .60}}; for (int i = 0; i < personalities.length; i++) { - Pet pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Toby", PetSex.MALE); + Pet pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Toby", PetSex.MALE); pet.personality(personalities[i]); double[] trust = {0, 12, 40, 100, -1, 6, 26}; double[] expected = {table[i][0], table[i][1], table[i][2], table[i][2], table[i][0], diff --git a/src/test/java/net/tfminecraft/companionpets/care/DominantNeedTest.java b/src/test/java/net/tfminecraft/companionpets/care/DominantNeedTest.java index a2ffc5e..4f9170a 100644 --- a/src/test/java/net/tfminecraft/companionpets/care/DominantNeedTest.java +++ b/src/test/java/net/tfminecraft/companionpets/care/DominantNeedTest.java @@ -38,6 +38,6 @@ void sameBandUsesHungerThenEnergyThenCleanlinessThenMood() { } private static Pet pet() { - return new Pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Luna", PetSex.FEMALE); + return net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Luna", PetSex.FEMALE); } } diff --git a/src/test/java/net/tfminecraft/companionpets/care/NeedClockTest.java b/src/test/java/net/tfminecraft/companionpets/care/NeedClockTest.java index 1db90f0..901be6e 100644 --- a/src/test/java/net/tfminecraft/companionpets/care/NeedClockTest.java +++ b/src/test/java/net/tfminecraft/companionpets/care/NeedClockTest.java @@ -255,7 +255,7 @@ void healthyPetWithZeroHealthRecoversWithoutMedicineOrMood() { org.mockbukkit.mockbukkit.MockBukkit.mock(); try { var care = net.tfminecraft.companionpets.config.CompanionConfig.load( - org.mockbukkit.mockbukkit.MockBukkit.createMockPlugin(), yaml).care(); + net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), yaml).care(); NeedClock.advance(pet, new CareInput(Presence.NEAR, false, false, true, true, true, minutes(1), care, 0.25)); } finally { org.mockbukkit.mockbukkit.MockBukkit.unmock(); } assertEquals(30, pet.need(Need.HEALTH), 0.001); @@ -312,7 +312,7 @@ private static Pet criticalPet() { } private static Pet pet() { - return new Pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Luna", PetSex.FEMALE); + return net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Luna", PetSex.FEMALE); } private static CareInput input( diff --git a/src/test/java/net/tfminecraft/companionpets/config/BehaviorProfileTest.java b/src/test/java/net/tfminecraft/companionpets/config/BehaviorProfileTest.java index 1dccac3..8ffdb62 100644 --- a/src/test/java/net/tfminecraft/companionpets/config/BehaviorProfileTest.java +++ b/src/test/java/net/tfminecraft/companionpets/config/BehaviorProfileTest.java @@ -47,7 +47,7 @@ class BehaviorProfileTest { dog: {entity: WOLF, egg: WOLF_SPAWN_EGG} cat: {entity: CAT, egg: CAT_SPAWN_EGG} """); - var config = CompanionConfig.load(MockBukkit.createMockPlugin(), yaml); + var config = CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), yaml); assertEquals(BehaviorProfile.CAT, config.type("fox").behavior()); assertEquals("minecraft:entity.fox.ambient", config.type("fox").sounds().cue(PetSounds.Event.GREETING).sounds().getFirst()); assertEquals(BehaviorProfile.DOG, config.type("exuberant").behavior()); @@ -57,7 +57,7 @@ class BehaviorProfileTest { } @Test void obsoleteListsAreIgnoredWithOneWarningAndUnknownProfilesListTheValidChoices() throws Exception { var warnings = new java.util.ArrayList(); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); Handler handler = new Handler() { public void publish(LogRecord record) { warnings.add(record.getMessage()); } public void flush() { } public void close() { } diff --git a/src/test/java/net/tfminecraft/companionpets/config/CompanionConfigCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/config/CompanionConfigCoverageTest.java index 8a2c197..0bc78ea 100644 --- a/src/test/java/net/tfminecraft/companionpets/config/CompanionConfigCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/config/CompanionConfigCoverageTest.java @@ -30,7 +30,7 @@ class CompanionConfigCoverageTest { @BeforeEach void setup() { MockBukkit.mock(); - plugin = MockBukkit.createMockPlugin(); + plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); plugin.getLogger().addHandler(handler); } @AfterEach void cleanup() { @@ -327,7 +327,7 @@ class CompanionConfigCoverageTest { } @Test void mythicOnlyTypeUsesTheDocumentedWolfFallbackWithAWarning() throws Exception { - MockBukkit.createMockPlugin("MythicMobs"); + net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin("MythicMobs"); var config = load("pets: {mythic: {mythic-mob: CustomPet, egg: BONE}}"); assertEquals(EntityType.WOLF, config.type("mythic").entity()); assertTrue(warnings.stream().anyMatch(s -> s.contains("assuming WOLF"))); diff --git a/src/test/java/net/tfminecraft/companionpets/config/ConfigExamplesTest.java b/src/test/java/net/tfminecraft/companionpets/config/ConfigExamplesTest.java index 8403177..414b33b 100644 --- a/src/test/java/net/tfminecraft/companionpets/config/ConfigExamplesTest.java +++ b/src/test/java/net/tfminecraft/companionpets/config/ConfigExamplesTest.java @@ -34,7 +34,7 @@ private String bundled() throws Exception { private CompanionConfig loadWithoutWarnings(String text) throws Exception { var yaml = new YamlConfiguration(); yaml.loadFromString(text); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); List warnings = new ArrayList<>(); Handler handler = new Handler() { @Override public void publish(LogRecord record) { diff --git a/src/test/java/net/tfminecraft/companionpets/config/CustomTrickTest.java b/src/test/java/net/tfminecraft/companionpets/config/CustomTrickTest.java index b3145d8..fbcf93f 100644 --- a/src/test/java/net/tfminecraft/companionpets/config/CustomTrickTest.java +++ b/src/test/java/net/tfminecraft/companionpets/config/CustomTrickTest.java @@ -58,7 +58,7 @@ public void flush() { } public void close() { } } @Test void customIdsKeepWordsAndProgressWithoutAnActiveDefinition() { - Pet pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "beagle", "Dog", PetSex.MALE); + Pet pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "beagle", "Dog", PetSex.MALE); Trick original = Trick.valueOf("salute"); pet.bindWord("hello", original); pet.progress(original, 75); Trick restored = Trick.valueOf(original.name()); diff --git a/src/test/java/net/tfminecraft/companionpets/config/ItemConfigTest.java b/src/test/java/net/tfminecraft/companionpets/config/ItemConfigTest.java index f2008cb..5e4179b 100644 --- a/src/test/java/net/tfminecraft/companionpets/config/ItemConfigTest.java +++ b/src/test/java/net/tfminecraft/companionpets/config/ItemConfigTest.java @@ -31,7 +31,7 @@ class ItemConfigTest { toys: [FEATHER] """); legacy.setDefaults(bundled); - var config = CompanionConfig.load(MockBukkit.createMockPlugin(), legacy); + var config = CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), legacy); var defaults = PetItems.defaults(); assertEquals(defaults.foods(), config.items().foods()); assertEquals(defaults.treats(), config.items().treats()); @@ -50,7 +50,7 @@ class ItemConfigTest { var legacy = new YamlConfiguration(); legacy.loadFromString("pets: {wolf: {entity: WOLF, egg: WOLF_SPAWN_EGG}}"); legacy.setDefaults(bundled); - var config = CompanionConfig.load(MockBukkit.createMockPlugin(), legacy); + var config = CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), legacy); assertEquals(PetItems.defaults(), config.items()); assertEquals(PetItems.defaults(), config.type("wolf").items()); } @@ -84,7 +84,7 @@ class ItemConfigTest { wolf: {entity: WOLF, egg: WOLF_SPAWN_EGG} cat: {entity: CAT, egg: CAT_SPAWN_EGG, items: {toys: [BONE]}} """); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var config = CompanionConfig.load(plugin, yaml); var stick = net.tfminecraft.companionpets.item.HeldItem.of(new org.bukkit.inventory.ItemStack(org.bukkit.Material.STICK)); assertEquals(java.util.Set.of(config.type("wolf")), config.toyTypes(stick)); @@ -127,7 +127,7 @@ class ItemConfigTest { @Test void shippedConfigContainsValidSelectorsAndFoodLists() { var yaml = YamlConfiguration.loadConfiguration(new InputStreamReader( getClass().getResourceAsStream("/config.yml"), StandardCharsets.UTF_8)); - var config = CompanionConfig.load(MockBukkit.createMockPlugin(), yaml); + var config = CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), yaml); assertEquals(java.util.List.of(ItemRef.parse("mmoitems:PETS:FISH_SNACK"), ItemRef.parse("mmoitems:PETS:BISCUIT_TREAT")), config.items().treats()); @@ -198,7 +198,7 @@ class ItemConfigTest { egg: m.pets.pet_other_egg egg-custom-model-data: 12003 """); - var config = CompanionConfig.load(MockBukkit.createMockPlugin(), yaml); + var config = CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), yaml); assertEquals(3, config.types().size()); assertEquals(java.util.List.of(ItemRef.parse("m.pets.caring_item")), config.items().brushes()); assertEquals(ItemRef.parse("ia.tfmc:pet_house_token"), config.kennel()); diff --git a/src/test/java/net/tfminecraft/companionpets/config/PetSoundsTest.java b/src/test/java/net/tfminecraft/companionpets/config/PetSoundsTest.java index fb5f4f0..d895323 100644 --- a/src/test/java/net/tfminecraft/companionpets/config/PetSoundsTest.java +++ b/src/test/java/net/tfminecraft/companionpets/config/PetSoundsTest.java @@ -78,7 +78,7 @@ private PetSounds read(String yaml) throws Exception { parrot: {entity: PARROT, egg: PARROT_SPAWN_EGG} horse: {entity: HORSE, egg: HORSE_SPAWN_EGG} """); - var config = CompanionConfig.load(MockBukkit.createMockPlugin(), yaml); + var config = CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), yaml); assertEquals(java.util.Set.of("dog", "cat"), config.types().keySet()); assertFalse(config.type("dog").nativeCombat()); } @@ -89,7 +89,7 @@ private PetSounds read(String yaml) throws Exception { rabbit: {species: dog, voice: rabbit, egg: RABBIT_SPAWN_EGG} pig: {species: cat, voice: pig, egg: PIG_SPAWN_EGG} """); - var config = CompanionConfig.load(MockBukkit.createMockPlugin(), yaml); + var config = CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), yaml); for (String entity : List.of("rabbit", "pig")) { var voice = config.type(entity).sounds(); assertFalse(voice.nativeSounds()); diff --git a/src/test/java/net/tfminecraft/companionpets/config/SpeciesConfigTest.java b/src/test/java/net/tfminecraft/companionpets/config/SpeciesConfigTest.java index 4d18a20..5e6a78f 100644 --- a/src/test/java/net/tfminecraft/companionpets/config/SpeciesConfigTest.java +++ b/src/test/java/net/tfminecraft/companionpets/config/SpeciesConfigTest.java @@ -17,7 +17,7 @@ class SpeciesConfigTest { private CompanionConfig load(String text) throws Exception { var yaml = new YamlConfiguration(); yaml.loadFromString(text); - return CompanionConfig.load(MockBukkit.createMockPlugin(), yaml); + return CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), yaml); } @Test void builtinsAndLegacyPetsShareEvolvingDefaultsAndNativeVoice() throws Exception { @@ -229,7 +229,7 @@ private CompanionConfig load(String text) throws Exception { } @Test void invalidPetAndTemplateEntitiesReportClearWarningsEvenWithAnOverride() throws Exception { - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var warnings = new java.util.ArrayList(); var handler = new java.util.logging.Handler() { public void publish(java.util.logging.LogRecord record) { warnings.add(record.getMessage()); } diff --git a/src/test/java/net/tfminecraft/companionpets/config/TfDevConfigurationTest.java b/src/test/java/net/tfminecraft/companionpets/config/TfDevConfigurationTest.java index 1b7a8ed..972c728 100644 --- a/src/test/java/net/tfminecraft/companionpets/config/TfDevConfigurationTest.java +++ b/src/test/java/net/tfminecraft/companionpets/config/TfDevConfigurationTest.java @@ -27,7 +27,7 @@ class TfDevConfigurationTest { try (var input = getClass().getResourceAsStream("/config.yml")) { bundledYaml.loadFromString(new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)); } - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var deployed = CompanionConfig.load(plugin, deployedYaml); var bundled = CompanionConfig.load(plugin, bundledYaml); assertGlobalSettings(deployed, bundled); @@ -63,13 +63,13 @@ class TfDevConfigurationTest { Path path = Path.of(System.getProperty("companionpets.tfdev-config")); var current = new YamlConfiguration(); current.load(path.toFile()); var original = new YamlConfiguration(); original.load(path.getParent().resolve("snapshots/2026-10-04-config.yml").toFile()); - var config = CompanionConfig.load(MockBukkit.createMockPlugin(), current); + var config = CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), current); var expectedTypes = new java.util.HashSet<>(original.getConfigurationSection("pets").getKeys(false)); expectedTypes.remove("fox_custom"); assertEquals(expectedTypes, config.types().keySet()); assertEquals(13, config.types().size()); // Compare effective settings: omitted defaults and legacy map/list forms are equivalent. - assertGlobalSettings(CompanionConfig.load(MockBukkit.createMockPlugin(), original), config); + assertGlobalSettings(CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), original), config); Path assets = path.getParent().getParent().getParent(); for (var pet : config.types().values()) { var saved = original.getConfigurationSection("pets." + (pet.id().equals("fox") ? "fox_custom" : pet.id())); diff --git a/src/test/java/net/tfminecraft/companionpets/config/TrainingValidationTest.java b/src/test/java/net/tfminecraft/companionpets/config/TrainingValidationTest.java index b5565cb..8dac383 100644 --- a/src/test/java/net/tfminecraft/companionpets/config/TrainingValidationTest.java +++ b/src/test/java/net/tfminecraft/companionpets/config/TrainingValidationTest.java @@ -12,12 +12,12 @@ class TrainingValidationTest { for (String training : new String[]{"{learned-at: 200}", "{learned-at: 0}", "{sometimes-at: 90, learned-at: 80}", "{attempts-before-bored: 0}", "{reward-window-seconds: 0}", "{session-distance: 0}"}) { var yaml = new YamlConfiguration(); yaml.loadFromString("training: " + training); - assertThrows(IllegalArgumentException.class, () -> CompanionConfig.load(MockBukkit.createMockPlugin(), yaml), training); + assertThrows(IllegalArgumentException.class, () -> CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), yaml), training); } } @Test void boundaryThresholdsLoadAndFullProgressCanObey() throws Exception { var yaml = new YamlConfiguration(); yaml.loadFromString("training: {sometimes-at: 0, learned-at: 100}"); - var config = CompanionConfig.load(MockBukkit.createMockPlugin(), yaml); + var config = CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), yaml); assertEquals(net.tfminecraft.companionpets.training.TrainingMath.Understanding.OBEYS, net.tfminecraft.companionpets.training.TrainingMath.understanding(100, config.training())); } @@ -27,13 +27,13 @@ class TrainingValidationTest { "play: {fetch-speed-multiplier: 0}", "play: {fetch-speed-multiplier: 4}", "limits: {max-pets: -1}", "limits: {max-stored: -1}", "limits: {max-out: -1}"}) { var yaml = new YamlConfiguration(); yaml.loadFromString(source); - assertThrows(IllegalArgumentException.class, () -> CompanionConfig.load(MockBukkit.createMockPlugin(), yaml), source); + assertThrows(IllegalArgumentException.class, () -> CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), yaml), source); } } @Test void invalidToyAttentionNumbersFallBackToDefaults() throws Exception { var yaml = new YamlConfiguration(); yaml.loadFromString("play: {toy-attention-seconds: -1, favorite-toy-attention-seconds: .nan}"); - var config = CompanionConfig.load(MockBukkit.createMockPlugin(), yaml); + var config = CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), yaml); assertEquals(20, config.play().toyAttentionSeconds()); assertEquals(35, config.play().favoriteToyAttentionSeconds()); } } diff --git a/src/test/java/net/tfminecraft/companionpets/estate/PetEstateTest.java b/src/test/java/net/tfminecraft/companionpets/estate/PetEstateTest.java new file mode 100644 index 0000000..69d81ea --- /dev/null +++ b/src/test/java/net/tfminecraft/companionpets/estate/PetEstateTest.java @@ -0,0 +1,390 @@ +package net.tfminecraft.companionpets.estate; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.*; +import java.time.Instant; +import java.util.*; +import org.bukkit.NamespacedKey; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockbukkit.mockbukkit.MockBukkit; +import net.tfminecraft.companionpets.body.Bodies; +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.Sessions; +import net.tfminecraft.companionpets.store.PetStore; +import net.tfminecraft.companionpets.testutil.OwnershipFixture; +import net.tfminecraft.companionpets.visual.IdleVisual; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.estate.*; + +class PetEstateTest { + @TempDir Path folder; + PetStore store; PetRuntime runtime; PetActions actions; PetEstate estate; Pet pet; + CharacterRef from = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + CharacterRef to = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + UUID death = UUID.randomUUID(); + @BeforeEach void prepare() throws Exception { + MockBukkit.mock(new net.tfminecraft.companionpets.testutil.GoalServerMock()); var plugin = OwnershipFixture.plugin(); + store = new PetStore(plugin); assertTrue(store.load()); + var config = new YamlConfiguration(); config.loadFromString("pets: {wolf: {entity: WOLF, egg: WOLF_SPAWN_EGG}}\nlimits: {max-pets: 1}\n"); + var visual = new IdleVisual(); var key = new NamespacedKey(plugin, "pet"); + runtime = new PetRuntime(plugin, CompanionConfig.load(plugin, config), store, new Sessions(), new Bodies(plugin,key,visual),visual,key,new NamespacedKey(plugin,"toy")); + actions = new PetActions(runtime); estate = new PetEstate(runtime, actions, folder); estate.guard = decision -> true; + pet = new Pet(UUID.randomUUID(), from.ownerId(), "removed-type", "Toby", PetSex.MALE); pet.ownership(from.ownerId(),from.characterId()); pet.stored(true); + pet.bond(88); pet.bindWord("sit", Trick.SIT); pet.progress(Trick.SIT, 99); pet.need(Need.HUNGER, 42); + pet.friends().restore(UUID.randomUUID(), new RelationshipMemory.Memory(15,1,2,3)); + pet.carers().restore(from.characterId(), new RelationshipMemory.Memory(17,1,2,3)); + store.add(pet); store.kennel("unloaded,1,2,3",from.ownerId(),from.characterId()); assertTrue(store.save());assertTrue(store.migrate()); + pet=store.get(pet.id()); + } + @AfterEach void cleanup() { if(store!=null)store.close(); MockBukkit.unmock(); } + EstateDecision decision(long revision, Recipient recipient) { + return new EstateDecision(death,from,Optional.of(Instant.EPOCH),Instant.EPOCH,Optional.of(to),Optional.empty(),Optional.empty(), + Recipient.FROZEN,recipient,Recipient.FROZEN,Recipient.SENDER,revision); + } + + @ParameterizedTest + @ValueSource(strings = {"pet", "kennel", "both"}) + void anotherDeathCannotLiftAStalePartialPetOrHouseHold(String holding) throws Exception { + var unrelated = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + if (holding.equals("pet")) store.kennel("unloaded,1,2,3", unrelated.ownerId(), unrelated.characterId()); + if (holding.equals("kennel")) pet.ownership(unrelated.ownerId(), unrelated.characterId()); + assertTrue(store.saveOwnership()); + assertInstanceOf(Outcome.Done.class, estate.settle(decision(0, Recipient.character(to)))); + Path first = folder.resolve(death + "-0.yml"); + var pending = OwnershipMigration.read(first); + pending.set("complete", false); + pending.set("moved", null); + DurableFiles.write(first, pending.saveToString()); + assertInstanceOf(Outcome.Blocked.class, estate.settle(decision(1, Recipient.character(to)))); + if (!holding.equals("kennel")) assertTrue(pet.ownershipHeld()); + if (!holding.equals("pet")) assertTrue(store.kennelHeld("unloaded,1,2,3")); + UUID secondDeath = UUID.randomUUID(); + var heir = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + var second = new EstateDecision(secondDeath, to, Optional.of(Instant.EPOCH), Instant.EPOCH, + Optional.of(heir), Optional.empty(), Optional.empty(), Recipient.FROZEN, + Recipient.character(heir), Recipient.FROZEN, Recipient.SENDER, 0); + Path source = runtime.plugin().getDataFolder().toPath().resolve("pets.yml"); + String before = Files.readString(source), intent = Files.readString(first); + assertInstanceOf(Outcome.Blocked.class, estate.settle(second)); + assertEquals(before, Files.readString(source)); + assertEquals(intent, Files.readString(first)); + assertFalse(Files.exists(folder.resolve(secondDeath + "-0.yml"))); + if (!holding.equals("kennel")) { + assertEquals(to.ownerId(), pet.ownerId()); + assertEquals(to.characterId(), pet.ownerCharacterId()); + assertTrue(pet.ownershipHeld()); + } + if (!holding.equals("pet")) { + assertEquals(to.ownerId(), store.kennelOwner("unloaded,1,2,3")); + assertEquals(to.characterId(), store.kennelCharacter("unloaded,1,2,3")); + assertTrue(store.kennelHeld("unloaded,1,2,3")); + } + } + + @Test + void completedEstateReportStaysDoneWhileTheRecipientsEstateIsIncomplete() throws Exception { + var first = decision(0, Recipient.character(to)); + var completed = estate.settle(first); + assertInstanceOf(Outcome.Done.class, completed); + Path firstReceipt = folder.resolve(death + "-0.yml"); + String firstBytes = Files.readString(firstReceipt); + UUID nextDeath = UUID.randomUUID(); + var heir = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + var second = new EstateDecision(nextDeath, to, Optional.of(Instant.EPOCH), Instant.EPOCH, + Optional.of(heir), Optional.empty(), Optional.empty(), Recipient.FROZEN, + Recipient.character(heir), Recipient.FROZEN, Recipient.SENDER, 0); + assertInstanceOf(Outcome.Done.class, estate.settle(second)); + Path secondReceipt = folder.resolve(nextDeath + "-0.yml"); + var partial = OwnershipMigration.read(secondReceipt); + partial.set("complete", false); partial.set("moved", null); + DurableFiles.write(secondReceipt, partial.saveToString()); + pet.ownershipHeld(true); store.holdKennel("unloaded,1,2,3", true); + Path source = runtime.plugin().getDataFolder().toPath().resolve("pets.yml"); + String sourceBytes = Files.readString(source), partialBytes = Files.readString(secondReceipt); + assertEquals(completed, estate.settle(first)); + assertEquals(firstBytes, Files.readString(firstReceipt)); + assertEquals(partialBytes, Files.readString(secondReceipt)); + assertEquals(sourceBytes, Files.readString(source)); + assertEquals(heir.characterId(), pet.ownerCharacterId()); + assertTrue(pet.ownershipHeld()); assertTrue(store.kennelHeld("unloaded,1,2,3")); + assertInstanceOf(Outcome.Done.class, estate.settle(second)); + assertFalse(pet.ownershipHeld()); assertFalse(store.kennelHeld("unloaded,1,2,3")); + assertEquals(completed, estate.settle(first)); + assertEquals(firstBytes, Files.readString(firstReceipt)); + } + + @Test + void unrelatedIncompleteDeathDoesNotBlockThisExactHeldIntentResume() throws Exception { + var original = decision(1, Recipient.character(to)); + assertInstanceOf(Outcome.Done.class, estate.settle(original)); + Path current = folder.resolve(death + "-1.yml"); + var own = OwnershipMigration.read(current); + own.set("complete", false); + own.set("moved", null); + DurableFiles.write(current, own.saveToString()); + pet.ownershipHeld(true); + store.holdKennel("unloaded,1,2,3", true); + UUID otherDeath = UUID.randomUUID(), otherPet = UUID.randomUUID(); + var otherFrom = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + var otherTo = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + var other = new EstateDecision(otherDeath, otherFrom, Optional.of(Instant.EPOCH), Instant.EPOCH, + Optional.of(otherTo), Optional.empty(), Optional.empty(), Recipient.FROZEN, + Recipient.character(otherTo), Recipient.FROZEN, Recipient.SENDER, 1); + var intent = new YamlConfiguration(); + intent.set("death", otherDeath.toString()); intent.set("revision", 1L); + intent.set("decision", other.toString()); intent.set("complete", false); + intent.set("from-account", otherFrom.ownerId().toString()); + intent.set("from-character", otherFrom.characterId().toString()); + intent.set("to-account", otherTo.ownerId().toString()); + intent.set("to-character", otherTo.characterId().toString()); + intent.set("pets", List.of(otherPet.toString())); intent.set("kennels", List.of()); + Path otherFile = folder.resolve(otherDeath + "-1.yml"); + DurableFiles.write(otherFile, intent.saveToString()); + String unchanged = Files.readString(otherFile); + assertInstanceOf(Outcome.Done.class, estate.settle(original)); + assertEquals(to.characterId(), pet.ownerCharacterId()); + assertFalse(pet.ownershipHeld()); + assertFalse(store.kennelHeld("unloaded,1,2,3")); + assertTrue(OwnershipMigration.read(current).getBoolean("complete")); + assertEquals(unchanged, Files.readString(otherFile)); + } + @Test void estateCannotSealAnEmptyIntentBeforeReviewedMigrationHasCaughtUpWithProviderReadiness() throws Exception { + Path file=runtime.plugin().getDataFolder().toPath().resolve("pets.yml"); + var legacy=OwnershipMigration.read(file);legacy.set("identity-version",null);legacy.set("pets."+pet.id()+".owner-character",null);DurableFiles.write(file,legacy.saveToString()); + assertTrue(store.load());pet=store.get(pet.id());assertFalse(store.ownershipReady());assertNull(pet.ownerCharacterId()); + var identity=org.mockito.Mockito.mock(net.tfminecraft.tlibs.character.CharacterIdentity.class);var ready=new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.when(identity.legacyHeirsReady()).thenAnswer(call->ready.get()); + org.mockito.Mockito.when(identity.legacyHeir(from.ownerId())).thenReturn(Optional.of(from)); + org.bukkit.Bukkit.getServicesManager().register(net.tfminecraft.tlibs.character.CharacterIdentity.class,identity,runtime.plugin(),org.bukkit.plugin.ServicePriority.Highest); + var decision=decision(1,Recipient.character(to));assertInstanceOf(Outcome.Retry.class,estate.settle(decision));assertFalse(Files.exists(folder.resolve(death+"-1.yml"))); + ready.set(true);assertInstanceOf(Outcome.Retry.class,estate.settle(decision));assertFalse(Files.exists(folder.resolve(death+"-1.yml"))); + assertTrue(store.migrate());pet=store.get(pet.id());assertTrue(store.ownershipReady());assertEquals(from.characterId(),pet.ownerCharacterId()); + assertInstanceOf(Outcome.Done.class,estate.settle(decision));assertEquals(to.characterId(),pet.ownerCharacterId()); + Files.writeString(file,Files.readString(file)+"external-progress: kept\n");assertFalse(store.save()); + assertInstanceOf(Outcome.Blocked.class,estate.settle(decision(2,Recipient.character(to)))); + } + @Test void globallyReadyDirectoryWithMissingAccountHeirCannotSealBeforeLaterReviewedResolution() throws Exception { + Path file=runtime.plugin().getDataFolder().toPath().resolve("pets.yml"); + var legacy=OwnershipMigration.read(file);legacy.set("pets."+pet.id()+".owner-character",null); + var houses=legacy.getMapList("kennels");houses.getFirst().remove("owner-character");legacy.set("kennels",houses);DurableFiles.write(file,legacy.saveToString());assertTrue(store.load()); + var identity=org.mockito.Mockito.mock(net.tfminecraft.tlibs.character.CharacterIdentity.class);var reviewed=new java.util.concurrent.atomic.AtomicReference>(Optional.empty()); + org.mockito.Mockito.when(identity.legacyHeirsReady()).thenReturn(true);org.mockito.Mockito.when(identity.legacyHeir(from.ownerId())).thenAnswer(call->reviewed.get()); + org.bukkit.Bukkit.getServicesManager().register(net.tfminecraft.tlibs.character.CharacterIdentity.class,identity,runtime.plugin(),org.bukkit.plugin.ServicePriority.Highest); + assertTrue(store.migrate());assertTrue(store.ownershipReady());assertTrue(store.hasUnassignedOwner(from.ownerId())); + assertTrue(store.load());assertTrue(store.migrate());assertTrue(store.hasUnassignedOwner(from.ownerId())); + var decision=decision(1,Recipient.character(to));assertInstanceOf(Outcome.Blocked.class,estate.settle(decision));assertFalse(Files.exists(folder.resolve(death+"-1.yml"))); + reviewed.set(Optional.of(from));assertTrue(store.migrate());assertFalse(store.hasUnassignedOwner(from.ownerId())); + assertInstanceOf(Outcome.Done.class,estate.settle(decision));assertEquals(to.characterId(),store.get(pet.id()).ownerCharacterId());assertEquals(to.characterId(),store.kennelCharacter("unloaded,1,2,3")); + } + @Test void staleCompletedEmptyReceiptNeverHidesLateResolvedPropertyAndRequiresANewRevision() throws Exception { + var decision=decision(1,Recipient.character(to));var empty=new YamlConfiguration(); + empty.set("death",death.toString());empty.set("revision",1L);empty.set("decision",decision.toString());empty.set("complete",true); + empty.set("from-account",from.ownerId().toString());empty.set("from-character",from.characterId().toString()); + empty.set("to-account",to.ownerId().toString());empty.set("to-character",to.characterId().toString()); + empty.set("pets",List.of());empty.set("kennels",List.of());empty.set("moved",List.of()); + Path receipt=folder.resolve(death+"-1.yml");DurableFiles.write(receipt,empty.saveToString());String original=Files.readString(receipt); + for(int restart=0;restart<2;restart++) { + assertTrue(store.load());assertTrue(store.migrate()); + assertInstanceOf(Outcome.Blocked.class,estate.settle(decision));assertEquals(original,Files.readString(receipt)); + assertEquals(from.characterId(),store.get(pet.id()).ownerCharacterId()); + } + store.get(pet.id()).ownership(to.ownerId(),to.characterId());assertTrue(store.saveOwnership()); + assertInstanceOf(Outcome.Blocked.class,estate.settle(decision));assertEquals(original,Files.readString(receipt)); + assertInstanceOf(Outcome.Done.class,estate.settle(decision(2,Recipient.character(to)))); + assertEquals(to.characterId(),store.get(pet.id()).ownerCharacterId());assertEquals(to.characterId(),store.kennelCharacter("unloaded,1,2,3")); + assertEquals(original,Files.readString(receipt)); + } + @Test void completedTransferIdsCannotBeTransferredAgainAfterAnOlderSourceEnvelopeReturns() { + assertInstanceOf(Outcome.Done.class,estate.settle(decision(1,Recipient.character(to)))); + pet.ownership(from.ownerId(),from.characterId());store.kennel("unloaded,1,2,3",from.ownerId(),from.characterId());assertTrue(store.saveOwnership()); + var other=new CharacterRef(UUID.randomUUID(),UUID.randomUUID()); + assertInstanceOf(Outcome.Blocked.class,estate.settle(decision(2,Recipient.character(other)))); + assertTrue(pet.ownershipHeld());assertTrue(store.kennelHeld("unloaded,1,2,3"));assertEquals(from.characterId(),pet.ownerCharacterId()); + assertFalse(Files.exists(folder.resolve(death+"-2.yml"))); + } + @Test void historicalReceiptRetainsItsOriginalRecipientUnderANewerDecision() { + var completed=estate.settle(decision(1,Recipient.character(to)));assertInstanceOf(Outcome.Done.class,completed); + var other=new CharacterRef(UUID.randomUUID(),UUID.randomUUID()); + assertEquals(completed,estate.settle(decision(2,Recipient.character(other)))); + assertEquals(to.ownerId(),pet.ownerId());assertEquals(to.characterId(),pet.ownerCharacterId()); + assertEquals(to.characterId(),store.kennelCharacter("unloaded,1,2,3")); + } + @Test void corruptedCompletedReceiptIdsCannotHideRestoredSourceOwnershipUnderANewRevision() throws Exception { + assertInstanceOf(Outcome.Done.class,estate.settle(decision(1,Recipient.character(to)))); + Path file=folder.resolve(death+"-1.yml");String original=Files.readString(file); + pet.ownership(from.ownerId(),from.characterId());store.kennel("unloaded,1,2,3",from.ownerId(),from.characterId());assertTrue(store.saveOwnership()); + Path source=runtime.plugin().getDataFolder().toPath().resolve("pets.yml");String restored=Files.readString(source); + for(String defect:List.of("missing","empty","changed","duplicate","type")) { + var plan=OwnershipMigration.parse(original);var moved=new ArrayList<>(plan.getStringList("moved")); + switch(defect) { + case "missing" -> plan.set("moved",null); + case "empty" -> plan.set("moved",List.of()); + case "changed" -> plan.set("moved",List.of("pet:"+UUID.randomUUID())); + case "duplicate" -> { moved.add(moved.getFirst());plan.set("moved",moved); } + default -> plan.set("moved",List.of(7)); + } + DurableFiles.write(file,plan.saveToString());assertInstanceOf(Outcome.Blocked.class,estate.settle(decision(2,Recipient.character(to))),defect); + assertEquals(from.characterId(),pet.ownerCharacterId());assertEquals(from.characterId(),store.kennelCharacter("unloaded,1,2,3")); + assertTrue(pet.ownershipHeld());assertTrue(store.kennelHeld("unloaded,1,2,3"));assertEquals(restored,Files.readString(source)); + assertFalse(Files.exists(folder.resolve(death+"-2.yml"))); + } + } + @Test void historicalReceiptsBindCapturedSourceRecipientAndIntegralRevisionBeforeCountingMovedIds() throws Exception { + var first=decision(1,Recipient.character(to));assertInstanceOf(Outcome.Done.class,estate.settle(first)); + Path file=folder.resolve(death+"-1.yml");String original=Files.readString(file); + var newer=decision(2,Recipient.character(new CharacterRef(UUID.randomUUID(),UUID.randomUUID()))); + for(String field:List.of("from-account","from-character","to-account","to-character","revision")) { + var plan=OwnershipMigration.parse(original);plan.set(field,field.equals("revision")?1.5:UUID.randomUUID().toString()); + assertEquals(first.toString(),plan.getString("decision"));DurableFiles.write(file,plan.saveToString()); + assertInstanceOf(Outcome.Blocked.class,estate.settle(newer),field);assertEquals(to.characterId(),pet.ownerCharacterId()); + assertTrue(pet.ownershipHeld());assertTrue(store.kennelHeld("unloaded,1,2,3"));assertFalse(Files.exists(folder.resolve(death+"-2.yml"))); + } + var plan=OwnershipMigration.parse(original);var otherSource=new CharacterRef(UUID.randomUUID(),UUID.randomUUID()); + var otherDecision=new EstateDecision(death,otherSource,first.diedAt(),first.decidedAt(),first.heir(),first.deathGuild(),first.deathRealm(), + first.money(),first.property(),first.guildLocks(),first.letters(),first.revision()); + plan.set("from-account",otherSource.ownerId().toString());plan.set("from-character",otherSource.characterId().toString());plan.set("decision",otherDecision.toString()); + DurableFiles.write(file,plan.saveToString());assertInstanceOf(Outcome.Blocked.class,estate.settle(newer)); + assertEquals(to.characterId(),pet.ownerCharacterId());assertFalse(Files.exists(folder.resolve(death+"-2.yml"))); + DurableFiles.write(file,original);assertInstanceOf(Outcome.Done.class,estate.settle(newer)); + } + @Test void unavailableSpeciesStillBindsNativeRoutingAndFollowerGateBeforeAndAfterEstate() { + var world=org.bukkit.Bukkit.getWorlds().isEmpty()?org.mockbukkit.mockbukkit.MockBukkit.getMock().addSimpleWorld("world"):org.bukkit.Bukkit.getWorlds().getFirst(); + var pathfinder=org.mockito.Mockito.mock(com.destroystokyo.paper.entity.Pathfinder.class); + var wolf=new org.mockbukkit.mockbukkit.entity.WolfMock(org.mockbukkit.mockbukkit.MockBukkit.getMock(),UUID.randomUUID()) { + @Override public com.destroystokyo.paper.entity.Pathfinder getPathfinder() { return pathfinder; } + @Override public boolean isInWater() { return false; } + }; + wolf.setLocation(new org.bukkit.Location(world,0,64,0));org.mockbukkit.mockbukkit.MockBukkit.getMock().registerEntity(wolf); + pet.stored(false);pet.entityId(wolf.getUniqueId()); + var key=new NamespacedKey(runtime.plugin(),"pet");wolf.getPersistentDataContainer().set(key,org.bukkit.persistence.PersistentDataType.STRING,pet.id().toString()); + @SuppressWarnings("unchecked") var nativeGoal=(com.destroystokyo.paper.entity.ai.Goal)org.mockito.Mockito.mock(com.destroystokyo.paper.entity.ai.Goal.class); + org.mockito.Mockito.when(nativeGoal.getKey()).thenReturn(com.destroystokyo.paper.entity.ai.VanillaGoal.FOLLOW_OWNER); + org.mockito.Mockito.when(nativeGoal.getTypes()).thenReturn(java.util.EnumSet.of(com.destroystokyo.paper.entity.ai.GoalType.MOVE)); + org.bukkit.Bukkit.getMobGoals().addGoal(wolf,6,nativeGoal); + actions.reattach(wolf);assertEquals(from.ownerId(),wolf.getOwner().getUniqueId()); + var followKey=com.destroystokyo.paper.entity.ai.GoalKey.of(org.bukkit.entity.Tameable.class,new NamespacedKey(runtime.plugin(),"owner_follow")); + assertNotNull(org.bukkit.Bukkit.getMobGoals().getGoal(wolf,followKey)); + assertInstanceOf(Outcome.Done.class,estate.settle(decision(1,Recipient.character(to)))); + assertEquals(to.ownerId(),wolf.getOwner().getUniqueId());assertNotNull(org.bukkit.Bukkit.getMobGoals().getGoal(wolf,followKey)); + } + @Test void propertyAndKennelsMoveOnceWhileStatsMemoriesAndReceiptCountsSurviveRestart() throws Exception { + var decision = decision(1,Recipient.character(to)); var friends=pet.friends().entries(); var carers=pet.carers().entries(); + assertEquals("companionpets",estate.id()); assertFalse(estate.describe(from.characterId()).equals(Holdings.NONE)); + assertInstanceOf(Outcome.Done.class, estate.settle(decision(1,Recipient.WAITING))); + var outcome=estate.settle(decision); assertInstanceOf(Outcome.Done.class,outcome); + assertEquals(to.ownerId(),pet.ownerId()); assertEquals(to.characterId(),pet.ownerCharacterId()); + assertEquals(88,pet.bond()); assertEquals(99,pet.progress(Trick.SIT)); assertEquals(42,pet.need(Need.HUNGER)); + assertEquals(friends,pet.friends().entries()); assertEquals(carers,pet.carers().entries()); + assertEquals(to.characterId(),store.kennelCharacter("unloaded,1,2,3")); + assertEquals(outcome,estate.settle(decision)); assertEquals(outcome,estate.settle(decision(2,Recipient.character(to)))); + assertEquals(Holdings.NONE,estate.describe(from.characterId())); + assertTrue(store.load());assertTrue(store.migrate()); assertEquals(to.characterId(),store.get(pet.id()).ownerCharacterId()); + assertEquals(outcome,estate.settle(decision)); + } + @Test void incompleteCurrentIntentRepairsTheReceiptButStalePublishedIntentBlocksAnotherRecipient() throws Exception { + var decision = decision(1,Recipient.character(to)); var outcome=estate.settle(decision); + Path file=folder.resolve(death+"-1.yml"); var plan=OwnershipMigration.read(file); plan.set("complete",false); DurableFiles.write(file,plan.saveToString()); + assertEquals(outcome,estate.settle(decision)); + plan.set("complete",false); DurableFiles.write(file,plan.saveToString()); + assertInstanceOf(Outcome.Blocked.class,estate.settle(decision(2,Recipient.character(new CharacterRef(UUID.randomUUID(),UUID.randomUUID()))))); + assertTrue(pet.ownershipHeld()); assertEquals(to.characterId(),pet.ownerCharacterId()); + } + @Test void changedOwnershipAndUnavailablePersistenceCannotBeOverwritten() throws Exception { + var decision=decision(1,Recipient.character(to)); assertInstanceOf(Outcome.Done.class,estate.settle(decision)); + Path file=folder.resolve(death+"-1.yml"); var plan=OwnershipMigration.read(file); plan.set("complete",false); DurableFiles.write(file,plan.saveToString()); + pet.ownership(UUID.randomUUID(),UUID.randomUUID()); assertInstanceOf(Outcome.Blocked.class,estate.settle(decision)); + plan.set("decision","wrong-recipient"); DurableFiles.write(file,plan.saveToString()); assertInstanceOf(Outcome.Blocked.class,estate.settle(decision)); + estate.guard=ignored->false; assertInstanceOf(Outcome.Retry.class,estate.settle(decision)); + estate.guard=ignored->true; Files.writeString(folder.resolve("broken.yml"),"[broken"); assertInstanceOf(Outcome.Blocked.class,estate.settle(decision)); + } + @Test void unstartedOldIntentCanBeReplannedButIoFailureHoldsAuthorityAndConflictingHouseBlocks() throws Exception { + var first=decision(1,Recipient.character(to)); + java.util.concurrent.atomic.AtomicInteger guardCalls=new java.util.concurrent.atomic.AtomicInteger(); + estate.guard=ignored->guardCalls.incrementAndGet()<3; + assertInstanceOf(Outcome.Retry.class,estate.settle(first));assertTrue(pet.ownershipHeld());assertTrue(store.kennelHeld("unloaded,1,2,3")); + estate.guard=ignored->true; + assertInstanceOf(Outcome.Done.class,estate.settle(decision(2,Recipient.character(to)))); + var second=OwnershipMigration.read(folder.resolve(death+"-2.yml"));second.set("complete",false);DurableFiles.write(folder.resolve(death+"-2.yml"),second.saveToString()); + store.kennel("unloaded,1,2,3",UUID.randomUUID(),UUID.randomUUID()); + assertInstanceOf(Outcome.Blocked.class,estate.settle(decision(2,Recipient.character(to)))); + var obsolete=OwnershipMigration.read(folder.resolve(death+"-1.yml"));assertTrue(obsolete.getBoolean("obsolete")); + obsolete.set("moved",List.of("pet:"+pet.id()));DurableFiles.write(folder.resolve(death+"-1.yml"),obsolete.saveToString()); + assertInstanceOf(Outcome.Blocked.class,estate.settle(decision(3,Recipient.character(to)))); + } + @Test void startupRecoveryHoldsIncompleteOrUnreadableJournalsAndHourlyReportsEachEstate() throws Exception { + var decision=decision(1,Recipient.character(to));assertInstanceOf(Outcome.Done.class,estate.settle(decision)); + var plan=OwnershipMigration.read(folder.resolve(death+"-1.yml"));plan.set("complete",false); + Path boot=runtime.plugin().getDataFolder().toPath().resolve("estates");Files.createDirectories(boot);DurableFiles.write(boot.resolve(death+"-1.yml"),plan.saveToString()); + PetEstate.start(runtime.plugin(),runtime,actions);assertTrue(pet.ownershipHeld());assertTrue(store.kennelHeld("unloaded,1,2,3")); + pet.ownershipHeld(false);store.holdKennel("unloaded,1,2,3",false); + Files.writeString(boot.resolve("broken.yml"),"[broken");PetEstate.start(runtime.plugin(),runtime,actions); + assertTrue(pet.ownershipHeld());assertTrue(store.kennelHeld("unloaded,1,2,3")); + var service=org.mockito.Mockito.mock(EstateService.class); + org.bukkit.Bukkit.getServicesManager().register(EstateService.class,service,runtime.plugin(),org.bukkit.plugin.ServicePriority.Highest); + estate.decided=()->List.of(decision);estate.guard=ignored->false;estate.reconcile();org.mockito.Mockito.verifyNoInteractions(service); + estate.guard=ignored->true;estate.reconcile();org.mockito.Mockito.verify(service).report(org.mockito.ArgumentMatchers.eq(death),org.mockito.ArgumentMatchers.eq("companionpets"),org.mockito.ArgumentMatchers.eq(1L),org.mockito.ArgumentMatchers.any(Outcome.class)); + } + @Test void malformedJournalBlocksButFilesystemFailureRemainsRetryable() throws Exception { + var decision=decision(1,Recipient.character(to));assertInstanceOf(Outcome.Done.class,estate.settle(decision)); + Path file=folder.resolve(death+"-1.yml");String original=Files.readString(file); + for(var defect:List.of("from-account","revision","complete","decision","pets","kennels","pet-id")) { + var plan=OwnershipMigration.parse(original); + switch(defect) { + case "from-account" -> plan.set(defect,"broken"); + case "revision" -> plan.set(defect,-1); + case "complete" -> plan.set(defect,"unknown"); + case "decision" -> plan.set(defect,""); + case "pet-id" -> plan.set("pets",List.of("bad-id")); + default -> plan.set(defect,List.of(7)); + } + DurableFiles.write(file,plan.saveToString()); + assertInstanceOf(Outcome.Blocked.class,estate.settle(decision),defect); + assertTrue(pet.ownershipHeld());assertTrue(store.kennelHeld("unloaded,1,2,3")); + } + DurableFiles.write(file,original);Files.move(file,folder.resolve("wrong-journal-path.yml")); + assertInstanceOf(Outcome.Blocked.class,estate.settle(decision)); + Path inaccessible=folder.resolve("not-a-directory");Files.writeString(inaccessible,"preserved"); + var unavailable=new PetEstate(runtime,actions,inaccessible);unavailable.guard=ignored->true; + assertInstanceOf(Outcome.Retry.class,unavailable.settle(decision));assertEquals("preserved",Files.readString(inaccessible)); + } + @Test void intactDecisionStringCannotAuthorizeCorruptRecipientSourceDeathOrRevisionEnvelope() throws Exception { + var decision=decision(1,Recipient.character(to)); + var guardCalls=new java.util.concurrent.atomic.AtomicInteger();estate.guard=ignored->guardCalls.incrementAndGet()<2; + assertInstanceOf(Outcome.Retry.class,estate.settle(decision));estate.guard=ignored->true; + Path file=folder.resolve(death+"-1.yml");String original=Files.readString(file); + for(String field:List.of("from-account","from-character","to-account","to-character","death","revision")) { + var plan=OwnershipMigration.parse(original);plan.set(field,field.equals("revision")?2L:UUID.randomUUID().toString()); + assertEquals(decision.toString(),plan.getString("decision"));DurableFiles.write(file,plan.saveToString()); + assertInstanceOf(Outcome.Blocked.class,estate.settle(decision),field); + assertEquals(from.ownerId(),pet.ownerId());assertEquals(from.characterId(),pet.ownerCharacterId()); + assertTrue(pet.ownershipHeld());assertTrue(store.kennelHeld("unloaded,1,2,3")); + } + var other=new CharacterRef(UUID.randomUUID(),UUID.randomUUID());var internallyValid=OwnershipMigration.parse(original); + internallyValid.set("to-account",other.ownerId().toString());internallyValid.set("to-character",other.characterId().toString()); + internallyValid.set("decision",decision(1,Recipient.character(other)).toString());DurableFiles.write(file,internallyValid.saveToString()); + assertInstanceOf(Outcome.Blocked.class,estate.settle(decision));assertEquals(from.characterId(),pet.ownerCharacterId()); + DurableFiles.write(file,original);assertInstanceOf(Outcome.Done.class,estate.settle(decision)); + } + @Test void temporaryJournalReadFailureRetainsReceiptAndRetriesWithoutRepublishingProperty() throws Exception { + var decision=decision(1,Recipient.character(to));assertInstanceOf(Outcome.Done.class,estate.settle(decision)); + Path file=folder.resolve(death+"-1.yml");String original=Files.readString(file); + try(var reader=org.mockito.Mockito.mockStatic(OwnershipMigration.class,org.mockito.Mockito.CALLS_REAL_METHODS)) { + reader.when(()->OwnershipMigration.read(file)).thenThrow(new java.io.IOException("transient filesystem read fault")); + assertInstanceOf(Outcome.Retry.class,estate.settle(decision)); + } + assertEquals(original,Files.readString(file));assertEquals(to.characterId(),pet.ownerCharacterId()); + assertInstanceOf(Outcome.Done.class,estate.settle(decision)); + } + @Test void conflictingExternalWriterBlocksEstateAndPreservesItsCurrentBytes() throws Exception { + Path file=runtime.plugin().getDataFolder().toPath().resolve("pets.yml");String changed=Files.readString(file)+"outside-progress: preserved\n";Files.writeString(file,changed); + assertInstanceOf(Outcome.Blocked.class,estate.settle(decision(1,Recipient.character(to)))); + assertEquals(changed,Files.readString(file));assertEquals(from.characterId(),pet.ownerCharacterId());assertTrue(pet.ownershipHeld()); + } +} diff --git a/src/test/java/net/tfminecraft/companionpets/fx/PetHologramsTest.java b/src/test/java/net/tfminecraft/companionpets/fx/PetHologramsTest.java index 4dd0611..c35dd56 100644 --- a/src/test/java/net/tfminecraft/companionpets/fx/PetHologramsTest.java +++ b/src/test/java/net/tfminecraft/companionpets/fx/PetHologramsTest.java @@ -12,7 +12,7 @@ class PetHologramsTest { @BeforeEach void start() { server=MockBukkit.mock(); server.addSimpleWorld("world"); } @AfterEach void stop() { MockBukkit.unmock(); } @Test void sleepLabelIsReusedAndRemovedWhenItsPetDisappears() { - var plugin=MockBukkit.createMockPlugin(); var labels=new PetHolograms(plugin); + var plugin=net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var labels=new PetHolograms(plugin); var pet=server.addPlayer(); pet.teleport(new Location(server.getWorlds().getFirst(),0,64,0)); labels.sleep(null,true); assertTrue(pet.getWorld().getEntitiesByClass(ArmorStand.class).isEmpty()); labels.sleep(pet,true); var stands=pet.getWorld().getEntitiesByClass(ArmorStand.class); @@ -26,7 +26,7 @@ class PetHologramsTest { labels.clear(); } @Test void oneTaskFollowsEveryLabelAndStopsWhenNoneRemain() throws Exception { - var plugin=MockBukkit.createMockPlugin(); var labels=new PetHolograms(plugin); + var plugin=net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var labels=new PetHolograms(plugin); var world=server.getWorlds().getFirst(); var first=server.addPlayer(); first.teleport(new Location(world,0,64,0)); var second=server.addPlayer(); second.teleport(new Location(world,8,64,0)); diff --git a/src/test/java/net/tfminecraft/companionpets/gui/PetMenuWorkflowTest.java b/src/test/java/net/tfminecraft/companionpets/gui/PetMenuWorkflowTest.java index 8b84329..496f5e1 100644 --- a/src/test/java/net/tfminecraft/companionpets/gui/PetMenuWorkflowTest.java +++ b/src/test/java/net/tfminecraft/companionpets/gui/PetMenuWorkflowTest.java @@ -32,7 +32,7 @@ class PetMenuWorkflowTest { @BeforeEach void setup() throws Exception { var server = MockBukkit.mock(); server.addSimpleWorld("world"); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); player = server.addPlayer(); var yaml = new YamlConfiguration(); yaml.loadFromString(""" @@ -57,12 +57,13 @@ class PetMenuWorkflowTest { var visual = new IdleVisual(); var key = new NamespacedKey(plugin, "pet"); runtime = new PetRuntime(plugin, config, new PetStore(plugin), new Sessions(), new Bodies(plugin, key, visual), visual, key, new NamespacedKey(plugin, "toy")); + assertTrue(runtime.store().load()); actions = new PetActions(runtime); menus = actions.menus(); listener = new PetListener(runtime, actions); } @AfterEach void teardown() { MockBukkit.unmock(); } private Pet pet(String type, UUID owner) { - Pet pet = new Pet(UUID.randomUUID(), owner, type, "Pet", PetSex.FEMALE); + Pet pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner, type, "Pet", PetSex.FEMALE); pet.stored(true); runtime.store().add(pet); return pet; } private MenuHolder holder() { return (MenuHolder) player.getOpenInventory().getTopInventory().getHolder(); } diff --git a/src/test/java/net/tfminecraft/companionpets/integration/ModelEngineBridgeTest.java b/src/test/java/net/tfminecraft/companionpets/integration/ModelEngineBridgeTest.java index 2ba249f..7a777ba 100644 --- a/src/test/java/net/tfminecraft/companionpets/integration/ModelEngineBridgeTest.java +++ b/src/test/java/net/tfminecraft/companionpets/integration/ModelEngineBridgeTest.java @@ -339,7 +339,7 @@ private static void compileVariant(String suffix, String source, String variant) @BeforeEach void setup() throws Exception { var server = MockBukkit.mock(); - plugin = MockBukkit.createMockPlugin(); + plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); player = server.addPlayer(); var world = server.addSimpleWorld("models"); body = world.spawn(new org.bukkit.Location(world, 0, 64, 0), org.bukkit.entity.Wolf.class); @@ -718,7 +718,7 @@ private PetVisual hookWithLogs(List records) throws Exception { @Test void hookAvailabilityTracksProviderEnablementAndInteractionUsesThePublicHook() throws Exception { assertFalse(ModelHook.available()); - var modelPlugin = MockBukkit.createMockPlugin("ModelEngine"); + var modelPlugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin("ModelEngine"); assertTrue(ModelHook.available()); org.bukkit.Bukkit.getPluginManager().disablePlugin(modelPlugin); assertFalse(ModelHook.available()); diff --git a/src/test/java/net/tfminecraft/companionpets/item/ItemBridgeTest.java b/src/test/java/net/tfminecraft/companionpets/item/ItemBridgeTest.java index ef1955e..f518b26 100644 --- a/src/test/java/net/tfminecraft/companionpets/item/ItemBridgeTest.java +++ b/src/test/java/net/tfminecraft/companionpets/item/ItemBridgeTest.java @@ -357,7 +357,7 @@ public static CustomStack getInstance(String id) { adder: {entity: WOLF, egg: 'ia.pets:egg', toys: ['ia.pets:egg']} mmo: {entity: WOLF, egg: m.pets.egg, toys: [m.pets.egg]} """); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var config = net.tfminecraft.companionpets.config.CompanionConfig.load(plugin, yaml); var stack = mmoItem(Material.BONE, "PETS", "EGG"); tag(stack, "ia_id", "pets:egg"); @@ -399,7 +399,7 @@ public static CustomStack getInstance(String id) { adder: {entity: CAT, egg: 'ia.pets:bone'} model: {entity: WOLF, egg: BONE, egg-custom-model-data: 42} """); - var config = net.tfminecraft.companionpets.config.CompanionConfig.load(MockBukkit.createMockPlugin(), yaml); + var config = net.tfminecraft.companionpets.config.CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), yaml); var mmoStack = mmoItem(Material.BONE, "TOY", "BONE"); var iaStack = iaItem(Material.BONE, "pets:bone"); for (ItemStack stack : List.of(mmoStack, iaStack)) { @@ -469,7 +469,7 @@ void productionSelectorsUseOneIdentityAndIndicesAgreeWithLinearMatching() throws var mmo = provider("MMOItems"); var ia = provider("ItemsAdder"); var yaml = new org.bukkit.configuration.file.YamlConfiguration(); yaml.load(new File(System.getProperty("companionpets.item-config"))); - var config = net.tfminecraft.companionpets.config.CompanionConfig.load(MockBukkit.createMockPlugin(), yaml); + var config = net.tfminecraft.companionpets.config.CompanionConfig.load(net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(), yaml); var refs = new java.util.LinkedHashSet(); for (String id : yaml.getConfigurationSection("pets").getKeys(false)) refs.add(ItemRef.parse(yaml.getString("pets." + id + ".egg"))); diff --git a/src/test/java/net/tfminecraft/companionpets/listen/FurniturePetHousesProviderTest.java b/src/test/java/net/tfminecraft/companionpets/listen/FurniturePetHousesProviderTest.java index 7ad9055..c9a6b97 100644 --- a/src/test/java/net/tfminecraft/companionpets/listen/FurniturePetHousesProviderTest.java +++ b/src/test/java/net/tfminecraft/companionpets/listen/FurniturePetHousesProviderTest.java @@ -137,7 +137,7 @@ public Action getAction() { @BeforeEach void setup() throws Exception { server = MockBukkit.mock(new ProviderServer()); var world = server.addSimpleWorld("houses"); - var plugin = MockBukkit.createMockPlugin("HouseCoverage"); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin("HouseCoverage"); plugin.getLogger().addHandler(new Handler() { @Override public void publish(LogRecord record) { logs.add(record.getMessage()); } @Override public void flush() { } diff --git a/src/test/java/net/tfminecraft/companionpets/listen/FurniturePetHousesTest.java b/src/test/java/net/tfminecraft/companionpets/listen/FurniturePetHousesTest.java index 2b39666..862e3cf 100644 --- a/src/test/java/net/tfminecraft/companionpets/listen/FurniturePetHousesTest.java +++ b/src/test/java/net/tfminecraft/companionpets/listen/FurniturePetHousesTest.java @@ -35,7 +35,7 @@ class FurniturePetHousesTest { @BeforeEach void setup() throws Exception { var server = MockBukkit.mock(); var world = server.addSimpleWorld("world"); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); owner = server.addPlayer(); furniture = server.addPlayer(); furniture.teleport(new Location(world, -2.5, 65, 3.5)); diff --git a/src/test/java/net/tfminecraft/companionpets/listen/PetListenerCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/listen/PetListenerCoverageTest.java index 06fafc9..71bebaa 100644 --- a/src/test/java/net/tfminecraft/companionpets/listen/PetListenerCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/listen/PetListenerCoverageTest.java @@ -71,7 +71,7 @@ class PetListenerCoverageTest { server.addWorld(world); owner = server.addPlayer(); owner.teleport(new Location(world, 0, 64, 0)); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var yaml = new YamlConfiguration(); yaml.loadFromString(""" items: {kennel: BARREL, kennel-block: BARREL, toys: [STICK], treats: [COD]} @@ -94,7 +94,7 @@ class PetListenerCoverageTest { actions = new PetActions(runtime); listener = new PetListener(runtime, actions); server.getPluginManager().registerEvents(listener, plugin); - pet = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.MALE); + pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.MALE); body = wolf(owner.getLocation().add(1, 0, 0)); body.getPersistentDataContainer().set(key, PersistentDataType.STRING, pet.id().toString()); for (Need need : Need.values()) pet.need(need, 80); @@ -311,7 +311,7 @@ private EntityDamageByEntityEvent attack(Entity attacker, Entity victim, double @Test void committedDamagePenalizesVictimOnceAndAnimatesBothPets() { var attackerBody = wolf(owner.getLocation().add(2, 0, 0)); - Pet attacker = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Luna", PetSex.FEMALE); + Pet attacker = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Luna", PetSex.FEMALE); runtime.store().add(attacker); runtime.remember(attacker, attackerBody); body.setSilent(true); diff --git a/src/test/java/net/tfminecraft/companionpets/ownership/CharacterOwnershipTest.java b/src/test/java/net/tfminecraft/companionpets/ownership/CharacterOwnershipTest.java new file mode 100644 index 0000000..d014782 --- /dev/null +++ b/src/test/java/net/tfminecraft/companionpets/ownership/CharacterOwnershipTest.java @@ -0,0 +1,119 @@ +package net.tfminecraft.companionpets.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.plugin.ServicePriority; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; +import net.tfminecraft.companionpets.pet.*; +import net.tfminecraft.companionpets.session.*; +import net.tfminecraft.companionpets.gui.MenuHolder; +import net.tfminecraft.companionpets.body.OwnerFollowGoal; +import net.tfminecraft.companionpets.testutil.GoalServerMock; +import net.tfminecraft.tlibs.character.*; +import com.destroystokyo.paper.entity.ai.*; + +class CharacterOwnershipTest { + @AfterEach void cleanup() { MockBukkit.unmock(); } + @Test void sameAccountSwitchRejectsOwnershipStalePromptsMenusAndNativeFollowerWithoutRemovingSafety() { + var server = MockBukkit.mock(new GoalServerMock()); var plugin = MockBukkit.createMockPlugin(); var player = server.addPlayer(); + UUID a = UUID.randomUUID(), b = UUID.randomUUID(); + var identity = mock(CharacterIdentity.class); var current = new java.util.concurrent.atomic.AtomicReference<>(new CharacterSession(new CharacterRef(player.getUniqueId(), a), 1)); + when(identity.currentSession(player)).thenAnswer(call -> Optional.ofNullable(current.get())); + when(identity.isCurrent(any())).thenAnswer(call -> call.getArgument(0).equals(current.get())); + when(identity.info(a)).thenReturn(Optional.of(new CharacterInfo(new CharacterRef(player.getUniqueId(), a), "A", CharacterStatus.ALIVE, null, Set.of(), 0, null))); + when(identity.info(b)).thenReturn(Optional.of(new CharacterInfo(new CharacterRef(player.getUniqueId(), b), "B", CharacterStatus.ALIVE, null, Set.of(), 0, null))); + Bukkit.getServicesManager().register(CharacterIdentity.class, identity, plugin, ServicePriority.Highest); + Pet pet = new Pet(UUID.randomUUID(), player.getUniqueId(), "wolf", "Toby", PetSex.MALE); pet.ownership(player.getUniqueId(), a); + OwnerSession captured = CharacterAccess.capture(player); assertTrue(CharacterAccess.owns(pet, player)); + var sessions = new Sessions(); sessions.enforceCharacters(); var hatch = new HatchPrompt("wolf", 99999); sessions.hatch(player.getUniqueId(), hatch); + var rename = new RenamePrompt(pet.id(), 99999); sessions.rename(player.getUniqueId(), rename); + sessions.release(player.getUniqueId(), new ReleasePrompt(pet.id(), 99999)); sessions.training(player.getUniqueId(), new TrainingSession(pet.id())); + var menu = new MenuHolder(MenuHolder.Kind.CARE, pet.id(), null); menu.capture(player); assertTrue(menu.current(player)); + var world = server.addSimpleWorld("world"); var wolf = world.spawn(new org.bukkit.Location(world, 0, 64, 0), org.bukkit.entity.Wolf.class); + @SuppressWarnings("unchecked") Goal nativeGoal = mock(Goal.class); + when(nativeGoal.getKey()).thenReturn(VanillaGoal.FOLLOW_OWNER); when(nativeGoal.getTypes()).thenReturn(EnumSet.of(GoalType.MOVE, GoalType.JUMP)); + when(nativeGoal.shouldActivate()).thenReturn(true); when(nativeGoal.shouldStayActive()).thenReturn(true); + server.getMobGoals().addGoal(wolf, 6, nativeGoal); + var assists=new ArrayList>(); + for(var ownerKey:List.of(VanillaGoal.OWNER_HURT_BY,VanillaGoal.OWNER_HURT)) { + @SuppressWarnings("unchecked") Goal assist=mock(Goal.class); + when(assist.getKey()).thenReturn(ownerKey);when(assist.getTypes()).thenReturn(EnumSet.of(GoalType.TARGET)); + when(assist.shouldActivate()).thenReturn(true);when(assist.shouldStayActive()).thenReturn(true);assists.add(assist); + server.getMobGoals().addGoal(wolf,1,assist); + } + GoalKey safetyKey = GoalKey.of(org.bukkit.entity.Mob.class, new NamespacedKey(plugin, "swim-safety")); + @SuppressWarnings("unchecked") Goal safety = mock(Goal.class); when(safety.getKey()).thenReturn(safetyKey); server.getMobGoals().addGoal(wolf, 0, safety); + OwnerFollowGoal.install(plugin, wolf, pet); + GoalKey key = GoalKey.of(org.bukkit.entity.Tameable.class, new NamespacedKey(plugin, "owner_follow")); + var wrapped = server.getMobGoals().getGoal(wolf, key); assertTrue(wrapped.shouldActivate()); assertTrue(wrapped.shouldStayActive()); wrapped.start(); wrapped.tick(); + assertEquals(nativeGoal.getTypes(), wrapped.getTypes()); assertEquals(key, wrapped.getKey()); + var wrappedAssists=List.of("owner_hurt_by","owner_hurt").stream().map(label->server.getMobGoals().getGoal(wolf,GoalKey.of(org.bukkit.entity.Tameable.class,new NamespacedKey(plugin,label)))).toList(); + wrappedAssists.forEach(assist->{assertTrue(assist.shouldActivate());assertTrue(assist.shouldStayActive());assist.start();assist.tick();}); + current.set(new CharacterSession(new CharacterRef(player.getUniqueId(), a), 99)); + assertTrue(CharacterAccess.owns(pet,player));assertFalse(wrapped.shouldStayActive()); + wrappedAssists.forEach(assist->assertFalse(assist.shouldStayActive())); + wrapped.stop();wrapped.start();wrappedAssists.forEach(assist->{assist.stop();assist.start();}); + current.set(new CharacterSession(new CharacterRef(player.getUniqueId(), b), 2)); + assertFalse(CharacterAccess.current(captured)); assertFalse(CharacterAccess.owns(pet, player)); assertFalse(menu.current(player)); + assertNull(sessions.hatch(player.getUniqueId())); assertNull(sessions.rename(player.getUniqueId())); assertNull(sessions.release(player.getUniqueId())); assertNull(sessions.training(player.getUniqueId())); + var nextRename=new RenamePrompt(pet.id(),999999);sessions.rename(player.getUniqueId(),nextRename); + assertNull(sessions.hatch(player.getUniqueId()));assertNull(sessions.release(player.getUniqueId()));assertNull(sessions.training(player.getUniqueId())); + assertSame(nextRename,sessions.privatePrompt(player.getUniqueId())); + assertFalse(wrapped.shouldActivate()); assertFalse(wrapped.shouldStayActive()); wrapped.start(); wrapped.tick(); wrapped.stop(); + wrappedAssists.forEach(assist->{assertFalse(assist.shouldActivate());assertFalse(assist.shouldStayActive());assist.start();assist.tick();assist.stop();}); + assists.forEach(assist->verify(assist,times(2)).start()); + assertSame(safety, server.getMobGoals().getGoal(wolf, safetyKey)); assertNull(server.getMobGoals().getGoal(wolf, VanillaGoal.FOLLOW_OWNER)); + OwnerFollowGoal.install(plugin, wolf, pet); assertSame(wrapped, server.getMobGoals().getGoal(wolf, key)); + current.set(null); sessions.clearPlayer(player.getUniqueId()); sessions.refresh(player.getUniqueId()); + sessions.hatch(player.getUniqueId(), hatch); assertNull(sessions.hatch(player.getUniqueId())); assertNull(sessions.token(player.getUniqueId())); + assertNull(CharacterAccess.capture(null)); assertNull(CharacterAccess.person(a)); + } + @Test void anotherAccountsLiveProviderTokenCannotAuthorizeThisConnectionOrCapturePrompts() { + var server=MockBukkit.mock();var plugin=MockBukkit.createMockPlugin();var player=server.addPlayer(); + var ref=new CharacterRef(UUID.randomUUID(),UUID.randomUUID());var token=new CharacterSession(ref,1);var identity=mock(CharacterIdentity.class); + when(identity.currentSession(player)).thenReturn(Optional.of(token));when(identity.isCurrent(token)).thenReturn(true); + when(identity.info(ref.characterId())).thenReturn(Optional.of(new CharacterInfo(ref,"Wrong account",CharacterStatus.ALIVE,null,Set.of(),0,null))); + Bukkit.getServicesManager().register(CharacterIdentity.class,identity,plugin,ServicePriority.Highest); + var pet=new Pet(UUID.randomUUID(),ref.ownerId(),"wolf","Other account pet",PetSex.MALE);pet.ownership(ref.ownerId(),ref.characterId()); + assertNull(CharacterAccess.capture(player));assertFalse(CharacterAccess.owns(pet,player)); + var sessions=new Sessions();sessions.enforceCharacters();sessions.hatch(player.getUniqueId(),new HatchPrompt("wolf",Long.MAX_VALUE)); + assertNull(sessions.hatch(player.getUniqueId()));assertNull(sessions.privatePrompt(player.getUniqueId()));assertNull(sessions.token(player.getUniqueId())); + var menu=new MenuHolder(MenuHolder.Kind.CARE,pet.id(),null);menu.capture(player);assertFalse(menu.current(player)); + } + @Test void staffCanResolveDeadIdentityForInspectionWhileCreationRequiresLivingAndReviewedHeirMayBeDead() { + var server=MockBukkit.mock();var plugin=MockBukkit.createMockPlugin();var player=server.addPlayer(); + UUID dead=UUID.randomUUID();var ref=new CharacterRef(player.getUniqueId(),dead);var identity=mock(CharacterIdentity.class); + var info=new CharacterInfo(ref,"Old Owner",CharacterStatus.DEAD,null,Set.of(),0,null); + when(identity.info(dead)).thenReturn(Optional.of(info));when(identity.findByName("Old Owner")).thenReturn(List.of(info)); + when(identity.legacyHeir(player.getUniqueId())).thenReturn(Optional.of(ref)); + Bukkit.getServicesManager().register(CharacterIdentity.class,identity,plugin,ServicePriority.Highest); + assertEquals(dead,CharacterAccess.resolve(dead.toString()));assertEquals(dead,CharacterAccess.resolve("Old Owner")); + assertFalse(CharacterAccess.alive(dead));assertFalse(CharacterAccess.alive(null));assertEquals(player.getUniqueId(),CharacterAccess.account(dead)); + assertEquals(Optional.of(dead),CharacterAccess.heir(player.getUniqueId())); + assertNull(CharacterAccess.resolve(UUID.randomUUID().toString()));assertNull(CharacterAccess.resolve("missing")); + } + @Test void lifecycleUsesOutgoingRefAfterItIsNoncurrentAndRejectsMissingProvider() { + var server=MockBukkit.mock();var plugin=MockBukkit.createMockPlugin();var player=server.addPlayer(); + var identity=mock(CharacterIdentity.class);var ref=new CharacterRef(player.getUniqueId(),UUID.randomUUID());var session=new CharacterSession(ref,3); + when(identity.currentSession(player)).thenReturn(Optional.of(session));when(identity.isCurrent(session)).thenReturn(true); + when(identity.info(ref.characterId())).thenReturn(Optional.of(new CharacterInfo(ref,"A",CharacterStatus.ALIVE,null,Set.of(),0,null))); + when(identity.legacyHeirsReady()).thenReturn(true);when(identity.legacyHeir(player.getUniqueId())).thenReturn(Optional.of(ref)); + Bukkit.getServicesManager().register(CharacterIdentity.class,identity,plugin,ServicePriority.Highest); + var sessions=new Sessions();sessions.enforceCharacters();sessions.hatch(player.getUniqueId(),new HatchPrompt("wolf",99999)); + var runtime=mock(net.tfminecraft.companionpets.runtime.PetRuntime.class);when(runtime.sessions()).thenReturn(sessions); + var actions=mock(net.tfminecraft.companionpets.runtime.PetActions.class);var lifecycle=new CharacterLifecycle(runtime,actions); + var holder=new MenuHolder(MenuHolder.Kind.CARE,UUID.randomUUID(),null);holder.capture(player);var inventory=Bukkit.createInventory(holder,9);holder.inventory(inventory);player.openInventory(inventory); + when(identity.currentSession(player)).thenReturn(Optional.empty());when(identity.isCurrent(session)).thenReturn(false); + lifecycle.end(new CharacterSessionEndEvent(player,session,CharacterSessionEndEvent.Reason.SWITCH)); + verify(actions).ownerSessionChanged(player);assertNull(sessions.privatePrompt(player.getUniqueId()));assertNotSame(inventory,player.getOpenInventory().getTopInventory()); + when(identity.currentSession(player)).thenReturn(Optional.of(session));when(identity.isCurrent(session)).thenReturn(true); + lifecycle.start(new CharacterSessionStartEvent(player,session));assertNotNull(sessions.token(player.getUniqueId())); + assertTrue(CharacterAccess.heirReady());assertEquals(Optional.of(ref.characterId()),CharacterAccess.heir(player.getUniqueId())); + player.disconnect();lifecycle.end(new CharacterSessionEndEvent(player,session,CharacterSessionEndEvent.Reason.QUIT));lifecycle.start(new CharacterSessionStartEvent(player,session)); + Bukkit.getServicesManager().unregisterAll(plugin);assertNull(CharacterAccess.capture(player));assertFalse(CharacterAccess.heirReady());assertEquals(Optional.empty(),CharacterAccess.heir(player.getUniqueId())); + } +} diff --git a/src/test/java/net/tfminecraft/companionpets/ownership/CompatibilityTest.java b/src/test/java/net/tfminecraft/companionpets/ownership/CompatibilityTest.java new file mode 100644 index 0000000..4bb31e9 --- /dev/null +++ b/src/test/java/net/tfminecraft/companionpets/ownership/CompatibilityTest.java @@ -0,0 +1,29 @@ +package net.tfminecraft.companionpets.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import java.net.URLClassLoader; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.bukkit.entity.Player; + +class CompatibilityTest { + @Test void absentNewApiClassesDoNotLinkIdentityOrEstateBridges() throws Exception { + var url=CharacterAccess.class.getProtectionDomain().getCodeSource().getLocation(); + try(var loader=new URLClassLoader(new java.net.URL[]{url},getClass().getClassLoader()) { + @Override protected Class loadClass(String name,boolean resolve) throws ClassNotFoundException { + if(name.startsWith("net.tfminecraft.tlibs.character."))throw new ClassNotFoundException(name); + if(name.startsWith("net.tfminecraft.companionpets.")) { + Class type=findLoadedClass(name);if(type==null)type=findClass(name);if(resolve)resolveClass(type);return type; + } + return super.loadClass(name,resolve); + } + }) { + var access=loader.loadClass(CharacterAccess.class.getName()); + assertEquals(false,access.getMethod("available").invoke(null)); + assertNull(access.getMethod("capture",Player.class).invoke(null,new Object[]{null})); + assertEquals(false,access.getMethod("heirReady").invoke(null)); + assertEquals(java.util.Optional.empty(),access.getMethod("heir",UUID.class).invoke(null,UUID.randomUUID())); + assertEquals(false,loader.loadClass("net.tfminecraft.companionpets.estate.EstateProbe").getMethod("available").invoke(null)); + } + } +} diff --git a/src/test/java/net/tfminecraft/companionpets/ownership/OwnershipMigrationTest.java b/src/test/java/net/tfminecraft/companionpets/ownership/OwnershipMigrationTest.java new file mode 100644 index 0000000..2de896f --- /dev/null +++ b/src/test/java/net/tfminecraft/companionpets/ownership/OwnershipMigrationTest.java @@ -0,0 +1,150 @@ +package net.tfminecraft.companionpets.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.*; +import java.util.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.bukkit.configuration.file.YamlConfiguration; + +class OwnershipMigrationTest { + @TempDir Path folder; + @Test void reviewedHeirPreservesUnknownsPetFriendshipsAndMissingCarersThroughReplay() throws Exception { + UUID account = UUID.randomUUID(), heir = UUID.randomUUID(), pet = UUID.randomUUID(), friend = UUID.randomUUID(), missing = UUID.randomUUID(); + Path source = folder.resolve("pets.yml"); + String original = "future-root: {nested: [1, 2], explicit-null: null}\nversion: 1\npets:\n " + pet + ":\n owner: " + account + + "\n type: removed-type\n future-field: {nested: secret-value}\n carers:\n " + account + + ": {trust: 15, future-memory: keep, explicit-null: null}\n " + missing + ": {trust: 21}\n friends:\n " + friend + + ": {trust: 16, future-friend: keep}\nkennels: [{world: unloaded, x: 3, y: 4, z: 5, owner: " + account + ", unknown: keep}]\n"; + Files.writeString(source, original); + var migration = new OwnershipMigration(source, id -> id.equals(account) ? Optional.of(heir) : Optional.empty()); + migration.run(); + var yaml = OwnershipMigration.read(source); String row = "pets." + pet; + assertEquals(heir.toString(), yaml.getString(row + ".owner-character")); + assertEquals(account.toString(), yaml.getString(row + ".owner")); + var raw = OwnershipMigration.raw(Files.readString(source)); + assertTrue(((Map) raw.get("future-root")).containsKey("explicit-null")); + + assertEquals("secret-value", yaml.getString(row + ".future-field.nested")); + assertEquals("keep", yaml.getString(row + ".carers." + heir + ".future-memory")); + assertEquals(21, yaml.getInt(row + ".legacy-carers." + missing + ".trust")); + assertEquals("keep", yaml.getString(row + ".friends." + friend + ".future-friend")); + assertEquals(heir.toString(), yaml.getMapList("kennels").getFirst().get("owner-character")); + assertEquals(original, Files.readString(folder.resolve("ownership-migrations/plan-0001.original.yml"))); + String once = Files.readString(source); migration.run(); assertEquals(once, Files.readString(source)); + var plan = OwnershipMigration.read(folder.resolve("ownership-migrations/plan-0001.yml")); + plan.set("complete", false); DurableFiles.write(folder.resolve("ownership-migrations/plan-0001.yml"), plan.saveToString()); + migration.run(); assertEquals(once, Files.readString(source)); + assertTrue(OwnershipMigration.read(folder.resolve("ownership-migrations/plan-0001.yml")).getBoolean("complete")); + } + @Test void incompletePlanDoesNotOverwriteProgressOrChangeItsFrozenRecipient() throws Exception { + Path source = folder.resolve("pets.yml"); + UUID account = UUID.randomUUID(), heir = UUID.randomUUID(); + String original = "pets: {one: {owner: " + account + ", type: wolf}}\nkennels: []\n"; + Files.writeString(source, original); + var migration = new OwnershipMigration(source, id -> Optional.of(heir)); migration.run(); + Path file = folder.resolve("ownership-migrations/plan-0001.yml"); + var plan = OwnershipMigration.read(file); plan.set("complete", false); DurableFiles.write(file, plan.saveToString()); + String changed = Files.readString(source) + "later-progress: kept\n"; Files.writeString(source, changed); + assertTrue(assertThrows(java.io.IOException.class, migration::run).getMessage().contains("BLOCKED")); + assertEquals(changed, Files.readString(source)); + Files.writeString(source, original); new OwnershipMigration(source, id -> Optional.of(UUID.randomUUID())).run(); + assertEquals(heir.toString(), OwnershipMigration.read(source).getString("pets.one.owner-character")); + } + @Test void archiveAndPlanFailureCutsReplayWithoutChangingTheFrozenRecipient() throws Exception { + UUID account=UUID.randomUUID(),heir=UUID.randomUUID();Path source=folder.resolve("pets.yml"); + String original="pets: {one: {owner: "+account+", type: wolf}}\nkennels: []\n";Files.writeString(source,original); + var migration=new OwnershipMigration(source,id->Optional.of(heir)); + try(var writes=org.mockito.Mockito.mockStatic(DurableFiles.class,org.mockito.Mockito.CALLS_REAL_METHODS)) { + writes.when(()->DurableFiles.write(org.mockito.ArgumentMatchers.eq(folder.resolve("ownership-migrations/plan-0001.yml")),org.mockito.ArgumentMatchers.anyString())) + .thenThrow(new java.io.IOException("plan fault")); + assertThrows(java.io.IOException.class,migration::run); + } + assertEquals(original,Files.readString(source));assertTrue(Files.exists(folder.resolve("ownership-migrations/plan-0001.original.yml"))); + try(var writes=org.mockito.Mockito.mockStatic(DurableFiles.class,org.mockito.Mockito.CALLS_REAL_METHODS)) { + writes.when(()->DurableFiles.write(org.mockito.ArgumentMatchers.eq(source),org.mockito.ArgumentMatchers.anyString())).thenThrow(new java.io.IOException("publication fault")); + assertThrows(java.io.IOException.class,migration::run); + } + assertEquals(original,Files.readString(source)); + new OwnershipMigration(source,id->Optional.of(UUID.randomUUID())).run(); + assertEquals(heir.toString(),OwnershipMigration.read(source).getString("pets.one.owner-character")); + } + + @Test void malformedAndFutureInputsStayIntactAndUnknownOwnersRemainHeld() throws Exception { + Path source = folder.resolve("pets.yml"); + Files.writeString(source, "pets: {one: {owner: malformed, type: wolf}}\nkennels: [{owner: malformed}]\n"); + new OwnershipMigration(source, id -> Optional.empty()).run(); + assertNull(OwnershipMigration.read(source).get("pets.one.owner-character")); + Files.writeString(source, "identity-version: 99\npets: {}\n"); + assertThrows(java.io.IOException.class, () -> new OwnershipMigration(source, id -> Optional.empty()).run()); + assertEquals("identity-version: 99\npets: {}\n", Files.readString(source)); + Files.writeString(source, "pets: [bad\n"); + assertThrows(java.io.IOException.class, () -> new OwnershipMigration(source, id -> Optional.empty()).run()); + assertNull(OwnershipMigration.uuid(null)); assertNull(OwnershipMigration.uuid("bad")); + + } + @Test void ancestorDirectorySyncFailureCannotAcknowledgeANewJournalAndRetryPreservesIt() throws Exception { + Path journal=folder.resolve("new-plugin-data/estates/intent.yml"),data=journal.getParent().getParent(); + try(var channels=org.mockito.Mockito.mockStatic(java.nio.channels.FileChannel.class,call->{ + if(call.getMethod().getName().equals("open")&&call.getArgument(0,Path.class).equals(data)) + throw new java.io.IOException("ancestor directory sync failure"); + return call.callRealMethod(); + })) { + assertThrows(java.io.IOException.class,()->DurableFiles.write(journal,"exact intent\n")); + } + assertEquals("exact intent\n",Files.readString(journal)); + DurableFiles.write(journal,"exact intent\n");assertEquals("exact intent\n",Files.readString(journal)); + } + @Test void malformedQuotedFractionalAndUnsupportedVersionsCannotBeNormalized() throws Exception { + Path source=folder.resolve("pets.yml"); + for(String version:List.of("null","'2'","future","2.5","0","3","4294967298",".NaN")) { + String original="identity-version: "+version+"\npets: {}\nkennels: []\n";Files.writeString(source,original); + assertThrows(java.io.IOException.class,()->new OwnershipMigration(source,id->Optional.empty()).run(),version); + assertEquals(original,Files.readString(source)); + } + assertNotNull(OwnershipMigration.raw("identity-version: 1\npets: {}\n")); + assertNotNull(OwnershipMigration.raw("identity-version: 2\npets: {}\n")); + } + @Test void duplicateOwnershipAndUnknownNestedKeysRemainUntouchedAndHeld() throws Exception { + Path source=folder.resolve("pets.yml"); + for(String field:List.of("owner","unknown")) { + String original="pets:\n one:\n type: wolf\n "+field+": first\n "+field+": second\nkennels: []\n"; + Files.writeString(source,original); + assertThrows(java.io.IOException.class,()->new OwnershipMigration(source,id->Optional.of(UUID.randomUUID())).run()); + assertEquals(original,Files.readString(source)); + assertFalse(Files.exists(folder.resolve("ownership-migrations/plan-0001.yml"))); + } + } + @Test void strictRawLoaderAcceptsDocumentsAboveBukkitsFormerDefaultCodePointLimit() throws Exception { + String opaque = "x".repeat(3_145_729); + var raw = OwnershipMigration.raw("pets: {}\nfuture: " + opaque + "\nkennels: []\n"); + assertEquals(opaque, raw.get("future")); + } + + @Test void orphanArchiveFromDifferentSourceMustBeReviewedBeforePlanning() throws Exception { + Path source=folder.resolve("pets.yml");String current="pets: {}\n";Files.writeString(source,current); + Path archive=folder.resolve("ownership-migrations/plan-0001.original.yml");Files.createDirectories(archive.getParent());Files.writeString(archive,"different source"); + assertTrue(assertThrows(java.io.IOException.class,()->new OwnershipMigration(source,id->Optional.empty()).run()).getMessage().contains("orphaned")); + assertEquals(current,Files.readString(source));assertEquals("different source",Files.readString(archive)); + } + @Test void corruptArchiveCannotPublishEvenWhenThePendingSourceMatchesItsPlan() throws Exception { + Path source=folder.resolve("pets.yml");String original="pets: {}\n";Files.writeString(source,original); + var migration=new OwnershipMigration(source,id->Optional.empty());migration.run(); + Path file=folder.resolve("ownership-migrations/plan-0001.yml");var plan=OwnershipMigration.read(file); + plan.set("complete",false);DurableFiles.write(file,plan.saveToString());Files.writeString(source,original); + Files.writeString(folder.resolve("ownership-migrations/plan-0001.original.yml"),"corrupt"); + assertTrue(assertThrows(java.io.IOException.class,migration::run).getMessage().contains("archive")); + assertEquals(original,Files.readString(source)); + } + @Test void corruptPlannedBytesMissingSourceAndUnavailableHashNeverPublish() throws Exception { + UUID account=UUID.randomUUID();Path source=folder.resolve("pets.yml");Files.writeString(source,"pets: {one: {owner: "+account+", type: wolf}}\n"); + var migration=new OwnershipMigration(source,id->Optional.of(UUID.randomUUID()));migration.run(); + Path file=folder.resolve("ownership-migrations/plan-0001.yml");var plan=OwnershipMigration.read(file);plan.set("complete",false);plan.set("planned","corrupt");DurableFiles.write(file,plan.saveToString()); + assertTrue(assertThrows(java.io.IOException.class,migration::run).getMessage().contains("corrupt")); + Files.delete(source);assertTrue(assertThrows(java.io.IOException.class,migration::run).getMessage().contains("missing")); + try(var hashes=org.mockito.Mockito.mockStatic(java.security.MessageDigest.class)) { + hashes.when(()->java.security.MessageDigest.getInstance("SHA-256")).thenThrow(new java.security.NoSuchAlgorithmException("unavailable")); + assertThrows(IllegalStateException.class,()->OwnershipMigration.hash("bytes")); + } + } +} diff --git a/src/test/java/net/tfminecraft/companionpets/pet/RelationshipMemoryTest.java b/src/test/java/net/tfminecraft/companionpets/pet/RelationshipMemoryTest.java index b888e39..96916ef 100644 --- a/src/test/java/net/tfminecraft/companionpets/pet/RelationshipMemoryTest.java +++ b/src/test/java/net/tfminecraft/companionpets/pet/RelationshipMemoryTest.java @@ -26,7 +26,7 @@ class RelationshipMemoryTest { } @Test void ownershipTransferClearsMemoriesAndAnticipationWithoutGrantingRights() { var owner = UUID.randomUUID(); var person = UUID.randomUUID(); - var pet = new Pet(UUID.randomUUID(), owner, "wolf", "Toby", PetSex.MALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner, "wolf", "Toby", PetSex.MALE); pet.carers().reinforce(person, 50, 1000, 0); pet.friends().reinforce(UUID.randomUUID(), 50, 1000, 0); assertEquals(owner, pet.ownerId()); pet.toyExcitedUntilMillis(10000); pet.ownerId(UUID.randomUUID()); assertTrue(pet.carers().entries().isEmpty()); assertTrue(pet.friends().entries().isEmpty()); diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/NavigationContractCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/NavigationContractCoverageTest.java index 7dd04f1..4e61cb9 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/NavigationContractCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/NavigationContractCoverageTest.java @@ -25,13 +25,13 @@ class NavigationContractCoverageTest { boolean inWater; @BeforeEach void setup() throws Exception { server=MockBukkit.mock(new GoalServerMock()); var world=server.addSimpleWorld("world"); - var plugin=MockBukkit.createMockPlugin(); var yaml=new YamlConfiguration(); + var plugin=net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var yaml=new YamlConfiguration(); yaml.loadFromString("pets:\n dog: {species: dog, egg: EGG}\n"); var config=CompanionConfig.load(plugin,yaml); var key=new NamespacedKey(plugin,"pet"); store=new PetStore(plugin); assertTrue(store.load()); var visual=new IdleVisual(); runtime=new PetRuntime(plugin,config,store,new Sessions(),new Bodies(plugin,key,visual),visual,key,new NamespacedKey(plugin,"toy")); actions=new PetActions(runtime); var owner=server.addPlayer(); - pet=new Pet(UUID.randomUUID(),owner.getUniqueId(),"dog","Toby",PetSex.MALE); pet.stored(false); + pet=net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(),owner.getUniqueId(),"dog","Toby",PetSex.MALE); pet.stored(false); path=mock(Pathfinder.class); body=new WolfMock(server,UUID.randomUUID()) { @Override public Pathfinder getPathfinder() { return path; } diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/PetActionsCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/PetActionsCoverageTest.java index a6e9af6..8740687 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/PetActionsCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/PetActionsCoverageTest.java @@ -108,7 +108,7 @@ class PetActionsCoverageTest { server.addWorld(world); owner = server.addPlayer(); owner.teleport(new Location(world, 0, 64, 0)); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); yaml = new YamlConfiguration(); yaml.loadFromString(""" moments: {belly-up: {chance: 0}} @@ -143,7 +143,7 @@ class PetActionsCoverageTest { actions = new PetActions(runtime); listener = new PetListener(runtime, actions); server.getPluginManager().registerEvents(listener, plugin); - pet = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.MALE); + pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.MALE); body = wolf(owner.getLocation().add(1, 0, 0)); body.getPersistentDataContainer().set(key, PersistentDataType.STRING, pet.id().toString()); for (Need need : Need.values()) pet.need(need, 80); @@ -412,7 +412,7 @@ private void command(String word, Trick trick) { } @Test void spacingOnlyCountsNearbyActivePetBodiesOnTheSameLevel() { - var other = new Pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Rex", PetSex.MALE); + var other = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Rex", PetSex.MALE); var otherBody = wolf(new Location(world, 10.5, 64, 0.5)); otherBody.getPersistentDataContainer().set(runtime.petKey(), PersistentDataType.STRING, other.id().toString()); runtime.store().add(other); runtime.remember(other, otherBody); @@ -427,7 +427,7 @@ private void command(String word, Trick trick) { } @Test void storingAtTheTotalLimitAlwaysSucceeds() { - Pet stored = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Stored", PetSex.FEMALE); + Pet stored = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Stored", PetSex.FEMALE); stored.stored(true); runtime.store().add(stored); assertEquals(2, runtime.store().countPets(owner.getUniqueId())); care(PetMenus.careSlot(PetMenus.STORE_SLOT, false, pet.stored())); @@ -441,7 +441,7 @@ private void command(String word, Trick trick) { @Test void activeLimitKeepsAnExistingStoredPetInTheHouse() { configure("limits.max-out", 1); - Pet stored = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Stored", PetSex.FEMALE); + Pet stored = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Stored", PetSex.FEMALE); stored.stored(true); runtime.store().add(stored); actions.menus().openCare(owner, stored); actions.clickMenu(owner, holder(), PetMenus.careSlot(PetMenus.BRING_OUT_SLOT, false), null, false, false, false); @@ -451,7 +451,7 @@ private void command(String word, Trick trick) { } @Test void testPetRejectsTheTotalWithRoomOutsideAndLeavesExistingPetsUntouched() { - Pet stored = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Stored", PetSex.FEMALE); + Pet stored = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Stored", PetSex.FEMALE); stored.stored(true); runtime.store().add(stored); int entities = world.getEntities().size(); assertFalse(actions.spawnTestPet(owner, "wolf", "Extra")); @@ -782,7 +782,7 @@ private void command(String word, Trick trick) { @Test void kennelPageNavigationAndSavedPetIconsOpenTheIntendedCareMenu() { for (int i = 0; i < 48; i++) { - Pet saved = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Pet" + i, PetSex.FEMALE); + Pet saved = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Pet" + i, PetSex.FEMALE); saved.stored(true); runtime.store().add(saved); } actions.menus().openKennel(owner); @@ -835,7 +835,7 @@ private void command(String word, Trick trick) { actions.onChat(owner, "Toby unfamiliar"); assertEquals(PetOrder.FOLLOW, pet.order()); assertTrue(bars().contains("has not learned")); - Pet twin = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.FEMALE); + Pet twin = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.FEMALE); var twinBody = wolf(owner.getLocation().add(2, 0, 0)); runtime.store().add(twin); runtime.remember(twin, twinBody); command("sit", Trick.SIT); @@ -845,7 +845,7 @@ private void command(String word, Trick trick) { } @Test void anUnavailableOptionalProviderRefusesCreationWithoutAddingAPet() { - MockBukkit.createMockPlugin("MythicMobs"); + net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin("MythicMobs"); configure("pets.wolf.mythic-mob", "UnavailableWolf"); assertNotNull(runtime.config().type("wolf")); assertFalse(net.tfminecraft.companionpets.integration.MythicSpawn.available()); diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/PetFetchActionsCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/PetFetchActionsCoverageTest.java index f790908..06af403 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/PetFetchActionsCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/PetFetchActionsCoverageTest.java @@ -93,7 +93,7 @@ class PetFetchActionsCoverageTest { }; server.addPlayer(owner); owner.teleport(new Location(world, 0, 64, 0)); owner.openInventory(server.createInventory(null, 9)); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); yaml = new YamlConfiguration(); yaml.loadFromString(""" items: {toys: [STICK]} moments: {enabled: false, belly-up: {enabled: false}, greeting: {enabled: false}} @@ -154,7 +154,7 @@ private Pet cat(boolean crouched) { } private Pet register(UUID ownerId, String type, Mob body) { - var result = new Pet(UUID.randomUUID(), ownerId, type, "Fetcher", PetSex.MALE); + var result = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), ownerId, type, "Fetcher", PetSex.MALE); for (Need need : Need.values()) result.need(need, 80); runtime.store().add(result); runtime.remember(result, body); return result; } diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/PetFetchWorkflowTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/PetFetchWorkflowTest.java index 002bbac..e0f9763 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/PetFetchWorkflowTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/PetFetchWorkflowTest.java @@ -90,7 +90,7 @@ public void apply(org.bukkit.entity.Entity entity, net.tfminecraft.companionpets }; }); } - }); var plugin = MockBukkit.createMockPlugin(); + }); var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); world = new net.tfminecraft.companionpets.testutil.CollisionWorldMock() { @Override public boolean isChunkLoaded(int x, int z) { return true; } @Override public void spawnParticle(org.bukkit.Particle particle, Location at, int count, @@ -117,7 +117,7 @@ private Pet outsidePet(UUID ownerId, String name, double x) { return outsidePet(ownerId, name, x, "wolf"); } private Pet outsidePet(UUID ownerId, String name, double x, String type) { - Pet result = new Pet(UUID.randomUUID(), ownerId, type, name, PetSex.MALE); + Pet result = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), ownerId, type, name, PetSex.MALE); runtime.store().add(result); var body = new FetchWolf(server, UUID.randomUUID(), navigationTargets, navigationSpeeds); server.registerEntity(body); @@ -977,7 +977,7 @@ private Pet pouncingCatWithAnApproachingCompetitor() { var yaml = new YamlConfiguration(); yaml.loadFromString("items: {toys: [STICK]}\npets: {wolf: {entity: WOLF, egg: WOLF_SPAWN_EGG}, cat: {entity: CAT, egg: CAT_SPAWN_EGG, items: {toys: []}}}\n"); runtime.config(CompanionConfig.load(runtime.plugin(), yaml)); - Pet cat = new Pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Cat", PetSex.FEMALE); + Pet cat = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Cat", PetSex.FEMALE); cat.stored(false); cat.entityId(resting.entityId()); runtime.store().add(cat); throwToy(); assertNotNull(pet.fetch()); assertNull(distant.fetch()); assertNull(resting.fetch()); diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/PetHatchingCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/PetHatchingCoverageTest.java index d85e28f..77a46db 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/PetHatchingCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/PetHatchingCoverageTest.java @@ -97,7 +97,7 @@ class PetHatchingCoverageTest { server.addWorld(world); player = server.addPlayer(); player.teleport(new Location(world, 0, 64, 0)); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); yaml = new YamlConfiguration(); yaml.loadFromString(""" limits: {max-out: 2, max-pets: 2} @@ -243,7 +243,7 @@ private void reload(String key, Object value) { } @Test void providerSpawnFailureKeepsTheConsumedEggAsASavedPetInTheHouse() { - MockBukkit.createMockPlugin("MythicMobs"); + net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin("MythicMobs"); reload("pets.wolf.mythic-mob", "UnavailableWolf"); assertNotNull(runtime.config().type("wolf")); assertFalse(MythicSpawn.available(), "fixture plugin advertises integration while its provider API is unavailable"); @@ -366,7 +366,7 @@ private void reload(String key, Object value) { private void fillPetHouse() { for (int i = 0; i < 2; i++) { - var stored = new Pet(UUID.randomUUID(), player.getUniqueId(), "wolf", "Stored" + i, net.tfminecraft.companionpets.pet.PetSex.FEMALE); + var stored = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), player.getUniqueId(), "wolf", "Stored" + i, net.tfminecraft.companionpets.pet.PetSex.FEMALE); stored.stored(true); runtime.store().add(stored); } } @@ -486,7 +486,7 @@ private void assertStoredAfterRejectedPlacement() { } @Test void mythicWolfWithoutNativeFollowingIsRemovedBeforeItCanBecomeAPet() throws Exception { - MockBukkit.createMockPlugin("MythicMobs"); + net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin("MythicMobs"); reload("pets.wolf.mythic-mob", "UntrainableWolf"); compileMythicProvider(); var logs = new ArrayList(); @@ -522,7 +522,7 @@ private void assertStoredAfterRejectedPlacement() { Class bodiesType = loader.loadClass(Bodies.class.getName()); Object bodies = bodiesType.getConstructor(JavaPlugin.class, NamespacedKey.class, PetVisual.class) .newInstance(runtime.plugin(), runtime.petKey(), runtime.visual()); - Pet candidate = new Pet(UUID.randomUUID(), player.getUniqueId(), "wolf", "Luna", PetSex.FEMALE); + Pet candidate = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), player.getUniqueId(), "wolf", "Luna", PetSex.FEMALE); Object spawned = bodiesType.getMethod("spawn", Pet.class, PetTypeDef.class, Location.class, Player.class) .invoke(bodies, candidate, runtime.config().type("wolf"), player.getLocation(), player); diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/PetInteractionTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/PetInteractionTest.java index bd40d2a..ec35939 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/PetInteractionTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/PetInteractionTest.java @@ -81,7 +81,7 @@ class PetInteractionTest { }); } }); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var world = new net.tfminecraft.companionpets.testutil.CollisionWorldMock() { @Override public void playSound(Location at, org.bukkit.Sound sound, float volume, float pitch) { greetingSounds.add(sound); greetingPitches.add(pitch); @@ -207,7 +207,7 @@ class PetInteractionTest { runtime = new PetRuntime(plugin, CompanionConfig.load(plugin, yaml), loadedStore(plugin), new Sessions(), new Bodies(plugin, key, visual), visual, key, new NamespacedKey(plugin, "toy")); actions = new PetActions(runtime); - pet = new Pet(UUID.randomUUID(), player.getUniqueId(), "wolf", "Toby", PetSex.MALE); + pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), player.getUniqueId(), "wolf", "Toby", PetSex.MALE); body = (WolfMock) world.spawn(player.getLocation().add(1, 0, 0), org.bukkit.entity.Wolf.class); body.getPersistentDataContainer().set(key, org.bukkit.persistence.PersistentDataType.STRING, pet.id().toString()); net.tfminecraft.companionpets.training.DefaultTricks.apply(runtime.config(), pet); runtime.store().add(pet); @@ -229,7 +229,7 @@ private void greetingSpecies(org.bukkit.entity.EntityType entity) { private void eagerGreeting() { pet.bond(100); pet.personality(PetPersonality.FRIENDLY); } @Test void ordinaryChatSkipsRaytracesAndPetIterationWithoutOwnedPetsOut() { - for (int i = 0; i < 500; i++) runtime.store().add(new Pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Other" + i, PetSex.MALE)); + for (int i = 0; i < 500; i++) runtime.store().add(net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Other" + i, PetSex.MALE)); var other = MockBukkit.getMock().addPlayer(); var store = spy(runtime.store()); var observed = spy(runtime); @@ -249,7 +249,7 @@ private void greetingSpecies(org.bukkit.entity.EntityType entity) { chat.onChat(player, "Toby"); assertEquals(Activity.ATTENDING, pet.activity()); verify(store, never()).active(); - verify(store).of(player.getUniqueId()); + verify(store).ofCharacter(player.getUniqueId()); verify(observed, never()).entity(argThat(candidate -> candidate != pet)); } @@ -1079,7 +1079,7 @@ private void assertCircleRadius(double base) { } private Pet secondPet(Location at) { - var other = new Pet(UUID.randomUUID(), player.getUniqueId(), "wolf", "Luna", PetSex.FEMALE); + var other = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), player.getUniqueId(), "wolf", "Luna", PetSex.FEMALE); var otherBody = at.getWorld().spawn(at, org.bukkit.entity.Wolf.class); otherBody.getPersistentDataContainer().set(runtime.petKey(), org.bukkit.persistence.PersistentDataType.STRING, other.id().toString()); runtime.store().add(other); runtime.remember(other, otherBody); other.bond(100); other.personality(PetPersonality.FRIENDLY); @@ -1823,7 +1823,7 @@ private ItemStack hold(Material material, int amount) { } @Test void explicitEmptyPetListsDisableAllGlobalCareItems() { - var selective = new Pet(UUID.randomUUID(), player.getUniqueId(), "selective", "Other", PetSex.FEMALE); + var selective = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), player.getUniqueId(), "selective", "Other", PetSex.FEMALE); runtime.store().add(selective); selective.stored(false); runtime.remember(selective, body); @@ -1873,6 +1873,7 @@ private void startHatch() { @Test void petHouseDeletesVisualAndBodyButPreservesLearningAndNeeds() { pet.progress(Trick.FOLLOW, 73); pet.bindWord("here", Trick.FOLLOW); pet.need(Need.HUNGER, 55); var holder = new net.tfminecraft.companionpets.gui.MenuHolder(net.tfminecraft.companionpets.gui.MenuHolder.Kind.CARE, pet.id(), null); + holder.capture(player); actions.clickMenu(player, holder, net.tfminecraft.companionpets.gui.PetMenus.careSlot( net.tfminecraft.companionpets.gui.PetMenus.STORE_SLOT, false, pet.stored()), null, false, false, false); assertEquals(1, bodyRemovals, "Pet House must not detach by revealing the vanilla mob"); @@ -1978,7 +1979,7 @@ private void startHatch() { @Test void ambiguousPetNamesDoNotExecuteCommandsOnEitherPet() { pet.order(PetOrder.SIT); - var other = new Pet(UUID.randomUUID(), player.getUniqueId(), "wolf", "Toby", PetSex.FEMALE); + var other = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), player.getUniqueId(), "wolf", "Toby", PetSex.FEMALE); other.order(PetOrder.SIT); runtime.store().add(other); var otherBody = body.getWorld().spawn(player.getLocation().add(2, 0, 0), org.bukkit.entity.Wolf.class); runtime.remember(other, otherBody); actions.onChat(player, "Toby follow"); @@ -2472,7 +2473,7 @@ private PlayerMock toyPlayer(String name, double x, Material toy) { greetingGround = true; body.setOnGround(true); pet.personality(PetPersonality.PLAYFUL); pet.favoriteToy("STICK"); PlayerMock visitor = toyPlayer("Visitor", 0, Material.STICK); PlayerMock secondOwner = toyPlayer("SecondOwner", 4, Material.AIR); - Pet other = new Pet(new UUID(0, 1), secondOwner.getUniqueId(), "wolf", "Luna", PetSex.FEMALE); + Pet other = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(new UUID(0, 1), secondOwner.getUniqueId(), "wolf", "Luna", PetSex.FEMALE); var otherBody = player.getWorld().spawn(new Location(player.getWorld(), -3, 64, 0), org.bukkit.entity.Wolf.class); runtime.store().add(other); runtime.remember(other, otherBody); other.personality(PetPersonality.PLAYFUL); other.favoriteToy("STICK"); diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/PetMomentsCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/PetMomentsCoverageTest.java index b95b3ca..ec17290 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/PetMomentsCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/PetMomentsCoverageTest.java @@ -68,7 +68,7 @@ class PetMomentsCoverageTest { for (int x = -8; x <= 16; x++) for (int z = -8; z <= 8; z++) world.getBlockAt(x, 63, z).setType(Material.STONE); owner = server.addPlayer(); owner.teleport(new Location(world, 0, 64, 0)); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); yaml = new YamlConfiguration(); yaml.loadFromString(""" moments: @@ -108,7 +108,7 @@ class PetMomentsCoverageTest { runtime = new PetRuntime(plugin, CompanionConfig.load(plugin, yaml), store, new Sessions(), new Bodies(plugin, key, visual), visual, key, new NamespacedKey(plugin, "toy")); moments = new PetMoments(runtime); - pet = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.MALE); + pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.MALE); pet.personality(PetPersonality.PLAYFUL); for (Need need : Need.values()) pet.need(need, 80); body = new WolfMock(server, UUID.randomUUID()) { diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/PetRoamingCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/PetRoamingCoverageTest.java index 40cf312..61a9cde 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/PetRoamingCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/PetRoamingCoverageTest.java @@ -71,7 +71,7 @@ class PetRoamingCoverageTest { server.addWorld(world); owner = server.addPlayer("Owner"); owner.teleport(new Location(world, 0, 64, 0)); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var yaml = new YamlConfiguration(); yaml.loadFromString("pets: {wolf: {entity: WOLF, egg: WOLF_SPAWN_EGG, sounds: false}}"); var visual = new PetVisual() { @@ -83,7 +83,7 @@ class PetRoamingCoverageTest { runtime = new PetRuntime(plugin, CompanionConfig.load(plugin, yaml), store, new Sessions(), new Bodies(plugin, key, visual), visual, key, new NamespacedKey(plugin, "toy")); actions = new PetActions(runtime); roaming = actions.roaming(); - pet = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.MALE); + pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.MALE); pet.bindWord("come", Trick.COME); pet.progress(Trick.COME, 100); store.add(pet); body = body(owner.getLocation().add(8, 0, 0)); @@ -209,7 +209,7 @@ private WolfMock body(Location location) { } @Test void clearReleasesNameAttentionListeningAndStationarityWithoutChangingOrders() { - var other = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Molly", PetSex.FEMALE); + var other = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Molly", PetSex.FEMALE); other.order(PetOrder.SIT); runtime.store().add(other); runtime.remember(other, body(owner.getLocation().add(3, 0, 0))); diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/PetSocialCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/PetSocialCoverageTest.java index 412f33d..815c98b 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/PetSocialCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/PetSocialCoverageTest.java @@ -74,7 +74,7 @@ private record Emission(Particle particle, Location at, int count) { } }; server.addWorld(world); owner = server.addPlayer(); owner.teleport(new Location(world, 0, 64, 0)); owner.openInventory(server.createInventory(null, 9)); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); yaml = new YamlConfiguration(); yaml.loadFromString(""" roaming: {stationary-seconds: 0} social: {encounter-chance: 100, chase-chance: 0, search-interval-seconds: 0.5} @@ -95,7 +95,7 @@ private record Emission(Particle particle, Location at, int count) { } @AfterEach void cleanup() { runtime.store().close(); MockBukkit.unmock(); } private Pet pet(double x) { - Pet result = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Friend " + x, PetSex.MALE); + Pet result = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Friend " + x, PetSex.MALE); result.personality(PetPersonality.FRIENDLY); result.bond(100); for (Need need : Need.values()) result.need(need, 100); SocialWolf body = new SocialWolf(); server.registerEntity(body); body.teleport(new Location(world, x, 64, 0)); diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/PetSpacingTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/PetSpacingTest.java index 2cd7970..e0766e3 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/PetSpacingTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/PetSpacingTest.java @@ -51,7 +51,7 @@ class PetSpacingTest { }; server.addWorld(world); elsewhere = server.addSimpleWorld("elsewhere"); owner = server.addPlayer(); owner.teleport(new Location(world, 0, 64, 0)); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var yaml = new YamlConfiguration(); yaml.loadFromString("pets: {wolf: {entity: WOLF, egg: WOLF_SPAWN_EGG}}\n"); var visual = new PetVisual() { @@ -66,7 +66,7 @@ class PetSpacingTest { @AfterEach void cleanup() { runtime.store().close(); MockBukkit.unmock(); } private Pet record(UUID id, UUID ownerId, Location at) { - Pet pet = new Pet(id, ownerId, "wolf", "Friend", PetSex.MALE); + Pet pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(id, ownerId, "wolf", "Friend", PetSex.MALE); pet.place(at.getWorld().getName(), at.getX(), at.getY(), at.getZ(), at.getYaw()); pet.entityId(UUID.randomUUID()); runtime.store().add(pet); return pet; diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/PetTickerCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/PetTickerCoverageTest.java index b712297..5e6cb47 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/PetTickerCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/PetTickerCoverageTest.java @@ -102,7 +102,7 @@ record Emission(Particle particle, int count, Object data) {} owner = server.addPlayer(); owner.teleport(new Location(world, 0, 64, 0)); owner.openInventory(server.createInventory(null, 9)); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); yaml = new YamlConfiguration(); yaml.loadFromString(""" care: @@ -141,7 +141,7 @@ record Emission(Particle particle, int count, Object data) {} new Bodies(plugin, key, visual), visual, key, new NamespacedKey(plugin, "toy")); actions = new PetActions(runtime); ticker = new PetTicker(runtime, actions); - pet = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.MALE); + pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.MALE); for (Need need : Need.values()) pet.need(need, 80); pet.bond(40); body = wolf(owner.getLocation().add(1, 0, 0)); @@ -331,7 +331,7 @@ private void assertSavedNeeds(double hunger) { @Test void failedSpawnRetriesWithBackoffAndNeverDecaysAMissingPet() throws Exception { body.remove(); // An enabled provider with an unavailable API is a supported reflective integration failure. - MockBukkit.createMockPlugin("MythicMobs"); + net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin("MythicMobs"); configure("pets.wolf.mythic-mob", "unavailable_pet"); var failedAttempts = new java.util.concurrent.atomic.AtomicInteger(); runtime.plugin().getLogger().addHandler(new java.util.logging.Handler() { @@ -646,7 +646,7 @@ private PlayNavigationGoal playGoal() { @Test void anActiveSocialEncounterRetainsControlOfBothPetsMovement() throws Exception { configure("social.enabled", true); pet.personality(PetPersonality.FRIENDLY); - Pet friend = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Buddy", PetSex.MALE); + Pet friend = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Buddy", PetSex.MALE); friend.personality(PetPersonality.FRIENDLY); for (Need need : Need.values()) friend.need(need, 80); WolfMock friendBody = wolf(owner.getLocation().add(2, 0, 0)); diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/PetTrainingCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/PetTrainingCoverageTest.java index 979c188..01aef3a 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/PetTrainingCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/PetTrainingCoverageTest.java @@ -52,7 +52,7 @@ class PetTrainingCoverageTest { world = server.addSimpleWorld("training"); owner = server.addPlayer(); owner.teleport(new Location(world, 0, 64, 0)); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); yaml = new YamlConfiguration(); yaml.loadFromString(""" items: {treats: [COD, SALMON]} @@ -76,7 +76,7 @@ class PetTrainingCoverageTest { var field = PetActions.class.getDeclaredField("training"); field.setAccessible(true); training = (PetTraining) field.get(actions); - pet = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.MALE); + pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.MALE); body = wolf(owner.getLocation().add(1, 0, 0)); body.getPersistentDataContainer().set(key, org.bukkit.persistence.PersistentDataType.STRING, pet.id().toString()); pet.stored(false); @@ -411,7 +411,7 @@ private String hologram() { attempt(false); training.beginTraining(owner, pet, body); assertSame(first, session()); - var second = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Milo", PetSex.FEMALE); + var second = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Milo", PetSex.FEMALE); second.stored(false); runtime.store().add(second); var secondBody = wolf(owner.getLocation().add(2, 0, 0)); diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/TestPetSpawnTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/TestPetSpawnTest.java index acc0c01..340fa96 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/TestPetSpawnTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/TestPetSpawnTest.java @@ -35,7 +35,7 @@ class TestPetSpawnTest { @BeforeEach void setup() throws Exception { var server = MockBukkit.mock(new net.tfminecraft.companionpets.testutil.GoalServerMock()); - JavaPlugin plugin = MockBukkit.createMockPlugin(); + JavaPlugin plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var world = new net.tfminecraft.companionpets.testutil.CollisionWorldMock() { @Override public T spawn(Location at, Class type) { Entity body; @@ -145,7 +145,7 @@ private static PetStore loadedStore(org.bukkit.plugin.java.JavaPlugin plugin) { } @Test void aBodyThatDoesNotFitIsPlacedOnFreeGroundBeforeItAppears() { - Pet pet = new Pet(java.util.UUID.randomUUID(), player.getUniqueId(), "wolf", "Toby", net.tfminecraft.companionpets.pet.PetSex.MALE); + Pet pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(java.util.UUID.randomUUID(), player.getUniqueId(), "wolf", "Toby", net.tfminecraft.companionpets.pet.PetSex.MALE); pet.order(net.tfminecraft.companionpets.pet.PetOrder.SIT); runtime.store().add(pet); teleportGuard = new net.tfminecraft.companionpets.listen.PetListener(runtime, actions); diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/TrainingLifecycleTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/TrainingLifecycleTest.java index 93e5dfc..0713520 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/TrainingLifecycleTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/TrainingLifecycleTest.java @@ -21,7 +21,7 @@ class TrainingLifecycleTest { @Test void offHandTreatIsReportedAsPutAwayRatherThanExhausted() throws Exception { var server = MockBukkit.mock(); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var player = server.addPlayer(); var config = new YamlConfiguration(); config.loadFromString("items: {treats: [COD, SALMON]}\npets: {wolf: {entity: WOLF, egg: WOLF_SPAWN_EGG}}"); @@ -31,7 +31,7 @@ class TrainingLifecycleTest { assertTrue(store.load()); var runtime = new PetRuntime(plugin, CompanionConfig.load(plugin, config), store, new Sessions(), new Bodies(plugin, key, visual), visual, key, new NamespacedKey(plugin, "toy")); - var pet = new Pet(UUID.randomUUID(), player.getUniqueId(), "wolf", "Toby", PetSex.MALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), player.getUniqueId(), "wolf", "Toby", PetSex.MALE); pet.stored(false); store.add(pet); var ticker = new PetTicker(runtime, new PetActions(runtime)); @@ -51,7 +51,7 @@ class TrainingLifecycleTest { @Test void missingPetTypeEndsTrainingWithoutThrowing() throws Exception { var server = MockBukkit.mock(); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var player = server.addPlayer(); var visual = new IdleVisual(); var key = new NamespacedKey(plugin, "pet"); @@ -59,7 +59,7 @@ class TrainingLifecycleTest { assertTrue(store.load()); var runtime = new PetRuntime(plugin, CompanionConfig.load(plugin, new YamlConfiguration()), store, new Sessions(), new Bodies(plugin, key, visual), visual, key, new NamespacedKey(plugin, "toy")); - var pet = new Pet(UUID.randomUUID(), player.getUniqueId(), "removed_type", "Toby", PetSex.MALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), player.getUniqueId(), "removed_type", "Toby", PetSex.MALE); pet.stored(false); store.add(pet); runtime.sessions().training(player.getUniqueId(), new TrainingSession(pet.id())); diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/WaterNavigationGoalTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/WaterNavigationGoalTest.java index 62691f1..a79bc8a 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/WaterNavigationGoalTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/WaterNavigationGoalTest.java @@ -69,7 +69,7 @@ class WaterNavigationGoalTest { server.addWorld(world); owner = server.addPlayer(); owner.teleport(new Location(world, 6.5, 64, 2.5)); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var yaml = new YamlConfiguration(); yaml.loadFromString(""" pets: {wolf: {entity: WOLF, egg: WOLF_SPAWN_EGG, default-tricks: [], sounds: false}} @@ -81,7 +81,7 @@ class WaterNavigationGoalTest { var store = new PetStore(plugin); assertTrue(store.load()); runtime = new PetRuntime(plugin, CompanionConfig.load(plugin, yaml), store, new Sessions(), new Bodies(plugin, key, visual), visual, key, new NamespacedKey(plugin, "toy")); - pet = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.MALE); + pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", "Toby", PetSex.MALE); pet.order(PetOrder.SIT); store.add(pet); path = mock(Pathfinder.class); diff --git a/src/test/java/net/tfminecraft/companionpets/staff/PetDiagnosticsTest.java b/src/test/java/net/tfminecraft/companionpets/staff/PetDiagnosticsTest.java index f975eea..d2bfadc 100644 --- a/src/test/java/net/tfminecraft/companionpets/staff/PetDiagnosticsTest.java +++ b/src/test/java/net/tfminecraft/companionpets/staff/PetDiagnosticsTest.java @@ -48,7 +48,7 @@ class PetDiagnosticsTest { @BeforeEach void setup() throws Exception { server = MockBukkit.mock(); server.addSimpleWorld("world"); - plugin = MockBukkit.createMockPlugin(); + plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); operator = server.addPlayer("Operator"); operator.setOp(true); var visual = new PetVisual() { @@ -118,7 +118,7 @@ class PetDiagnosticsTest { } @Test void configuredFurnitureReportsProviderApiFailureAndStillChecksOtherReferences() throws Exception { - MockBukkit.createMockPlugin("ItemsAdder"); + net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin("ItemsAdder"); runtime.config(config(""" items: {kennel: 'itemsadder:tfmc:house', kennel-furniture: 'itemsadder:tfmc:house'} pets: {wolf: {entity: WOLF, egg: WOLF_SPAWN_EGG}} @@ -130,7 +130,7 @@ class PetDiagnosticsTest { } @Test void anEnabledMythicProviderWithAnUnavailableApiProducesAnActionableWarning() throws Exception { - MockBukkit.createMockPlugin("MythicMobs"); + net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin("MythicMobs"); runtime.config(config("pets: {wolf: {entity: WOLF, egg: WOLF_SPAWN_EGG, mythic-mob: CompanionWolf}}")); assertNotNull(runtime.config().type("wolf"), "Enabled provider retains the configured type"); assertEquals(List.of("wolf: MythicMob unavailable: CompanionWolf"), diagnostics.validate()); @@ -276,7 +276,7 @@ private CompanionConfig config(String text) throws Exception { } private Pet add(String type) { - var pet = new Pet(UUID.randomUUID(), operator.getUniqueId(), type, "Toby", PetSex.MALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), operator.getUniqueId(), type, "Toby", PetSex.MALE); runtime.store().add(pet); return pet; } diff --git a/src/test/java/net/tfminecraft/companionpets/staff/StaffCommandsTest.java b/src/test/java/net/tfminecraft/companionpets/staff/StaffCommandsTest.java index f93f78a..bdeffa2 100644 --- a/src/test/java/net/tfminecraft/companionpets/staff/StaffCommandsTest.java +++ b/src/test/java/net/tfminecraft/companionpets/staff/StaffCommandsTest.java @@ -117,7 +117,7 @@ private String staffAudit() throws Exception { return super.getOfflinePlayers(); } }); server.addSimpleWorld("world"); - plugin = MockBukkit.createMockPlugin(); + plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); staff = server.addPlayer("Staff"); staff.setOp(true); staff.openInventory(server.createInventory(null, 9)); owner = server.addPlayer("Owner"); var yaml = new YamlConfiguration(); @@ -335,7 +335,7 @@ private void assertBackground() { assertEquals(entities, owner.getWorld().getEntities().size()); assertEquals(1, runtime.store().all().size()); assertEquals("missing_world", pet.worldName()); } private Pet add(UUID owner, String type, String name, boolean stored) { - var pet = new Pet(UUID.randomUUID(), owner, type, name, PetSex.MALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner, type, name, PetSex.MALE); pet.stored(stored); runtime.store().add(pet); return pet; } private void command(String... args) { assertTrue(commands.execute(staff, args)); } @@ -454,7 +454,13 @@ private Pet add(UUID owner, String type, String name, boolean stored) { menu().click(18); assertEquals(pet.id(), menu().subject()); } - @Test void legacyTestFlagsAreIgnoredAndNotWrittenBack() throws Exception { + @Test void staffCanListHeldRecordsWithoutAssigningThemToTheActiveCharacter() { + pet.ownership(owner.getUniqueId(),null);runtime.store().kennel("missing,1,2,3",owner.getUniqueId()); + var console=server.getConsoleSender();commands.execute(console,"list","unassigned");assertTrue(console.nextMessage().contains("2")); + assertTrue(console.nextMessage().contains(pet.id().toString()));assertTrue(console.nextMessage().contains("missing,1,2,3"));assertNull(pet.ownerCharacterId()); + } + + @Test void legacyTestFlagsRemainInertAndRawMetadataSurvives() throws Exception { assertTrue(runtime.store().save()); var file = new java.io.File(plugin.getDataFolder(), "pets.yml"); var yaml = YamlConfiguration.loadConfiguration(file); @@ -462,7 +468,7 @@ private Pet add(UUID owner, String type, String name, boolean stored) { var reloaded = new PetStore(plugin); assertTrue(reloaded.load()); assertNotNull(reloaded.get(pet.id())); assertTrue(reloaded.save()); String saved = Files.readString(file.toPath()); - assertFalse(saved.contains("test-pet")); assertFalse(saved.contains("test-frozen")); + assertTrue(saved.contains("test-pet")); assertTrue(saved.contains("test-frozen")); } @Test void consoleNamedProfileReportsSavedNeedsOrderIllnessAndLearnedWords() { diff --git a/src/test/java/net/tfminecraft/companionpets/staff/StaffMenusCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/staff/StaffMenusCoverageTest.java index 81d184f..8664572 100644 --- a/src/test/java/net/tfminecraft/companionpets/staff/StaffMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/staff/StaffMenusCoverageTest.java @@ -50,7 +50,7 @@ class StaffMenusCoverageTest { staff = server.addPlayer("Staff"); staff.setOp(true); owner = server.addPlayer("Owner"); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var yaml = new YamlConfiguration(); yaml.loadFromString("pets: {wolf: {entity: WOLF, egg: WOLF_SPAWN_EGG, default-tricks: []}}"); var custom = new ArrayList(); @@ -81,7 +81,7 @@ class StaffMenusCoverageTest { } private Pet pet(String name) { - var pet = new Pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", name, PetSex.FEMALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "wolf", name, PetSex.FEMALE); pet.stored(true); runtime.store().add(pet); return pet; @@ -163,7 +163,7 @@ private List pageNames() { @Test void ownerListPaginationSelectsTheActualPetOnTheSecondPage() { var pets = new ArrayList(); for (int i = 0; i < 47; i++) pets.add(pet("Pet " + String.format("%02d", i))); - var outsider = new Pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Pet 00", PetSex.MALE); + var outsider = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Pet 00", PetSex.MALE); outsider.stored(true); runtime.store().add(outsider); menus.list(staff, owner.getUniqueId(), 0); diff --git a/src/test/java/net/tfminecraft/companionpets/staff/TestCommandsCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/staff/TestCommandsCoverageTest.java index 9afc8de..20be2fa 100644 --- a/src/test/java/net/tfminecraft/companionpets/staff/TestCommandsCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/staff/TestCommandsCoverageTest.java @@ -77,7 +77,7 @@ class TestCommandsCoverageTest { @Override public void playSound(Location at, Sound sound, float volume, float pitch) { sounds.add(sound); } }; server.addWorld(world); - plugin = MockBukkit.createMockPlugin(); + plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); staff = server.addPlayer("Staff"); staff.setOp(true); staff.teleport(new Location(world, 0, 64, 0)); var yaml = new YamlConfiguration(); @@ -92,7 +92,7 @@ class TestCommandsCoverageTest { new Bodies(plugin, key, visual), visual, key, new NamespacedKey(plugin, "toy")); var actions = new PetActions(runtime); commands = new TestCommands(runtime, actions, new StaffCommands(runtime, actions)); - existing = new Pet(UUID.randomUUID(), staff.getUniqueId(), "wolf", "Existing", PetSex.MALE); + existing = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), staff.getUniqueId(), "wolf", "Existing", PetSex.MALE); existing.stored(true); store.add(existing); } @@ -177,7 +177,7 @@ private void bringOut(Pet pet) { execute("moment", "affection"); assertEquals("Look at one of your pets first.", staff.nextMessage()); var otherOwner = server.addPlayer("Other"); - var other = new Pet(UUID.randomUUID(), otherOwner.getUniqueId(), "wolf", "OtherPet", PetSex.FEMALE); + var other = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), otherOwner.getUniqueId(), "wolf", "OtherPet", PetSex.FEMALE); runtime.store().add(other); bringOut(other); execute("moment", "affection"); assertEquals("Look at one of your pets first.", staff.nextMessage()); diff --git a/src/test/java/net/tfminecraft/companionpets/store/PetStoreTest.java b/src/test/java/net/tfminecraft/companionpets/store/PetStoreTest.java index c8ba56f..358cd96 100644 --- a/src/test/java/net/tfminecraft/companionpets/store/PetStoreTest.java +++ b/src/test/java/net/tfminecraft/companionpets/store/PetStoreTest.java @@ -57,9 +57,210 @@ private PetStore store() { return new PetStore(new File(directory.toFile(), "pets.yml"), logger); } + @Test + void sixtyPetsWithEmptyRowListsSurviveOrdinarySaveAndReload() throws Exception { + var current = store(); + assertTrue(current.load()); + UUID account = UUID.randomUUID(), character = UUID.randomUUID(); + var ids = new ArrayList(); + for (int i = 0; i < 60; i++) { + var pet = new Pet(UUID.randomUUID(), account, "wolf", "Pet " + i, PetSex.MALE); + pet.ownership(account, character); + pet.bond(i); + current.add(pet); + ids.add(pet.id()); + } + assertTrue(current.save()); + String saved = Files.readString(directory.resolve("pets.yml")); + assertTrue(saved.lines().filter(line -> line.contains("*id")).count() > 50); + assertTrue(current.close()); + var restored = store(); + assertTrue(restored.load()); + assertEquals(60, restored.all().size()); + for (int i = 0; i < ids.size(); i++) { + var pet = restored.get(ids.get(i)); + assertEquals("Pet " + i, pet.name()); + assertEquals(account, pet.ownerId()); + assertEquals(character, pet.ownerCharacterId()); + assertEquals(i, pet.bond()); + assertTrue(pet.words().isEmpty()); + assertTrue(pet.announcedLowView().isEmpty()); + } + assertTrue(restored.close()); + } + + @Test + void deferredHeirsMigrateSixtyPetsAfterGameplaySaveAndCloseWithoutRecoveryMarker() + throws Exception { + org.mockbukkit.mockbukkit.MockBukkit.mock(); + try { + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); + UUID account = UUID.randomUUID(), character = UUID.randomUUID(); + var identity = mock(net.tfminecraft.tlibs.character.CharacterIdentity.class); + var ready = new AtomicBoolean(); + when(identity.legacyHeirsReady()).thenAnswer(call -> ready.get()); + when(identity.legacyHeir(account)).thenReturn(java.util.Optional.of( + new net.tfminecraft.tlibs.character.CharacterRef(account, character))); + org.bukkit.Bukkit.getServicesManager().register( + net.tfminecraft.tlibs.character.CharacterIdentity.class, identity, plugin, + org.bukkit.plugin.ServicePriority.Highest); + var current = store(); + assertTrue(current.load()); + var ids = new ArrayList(); + for (int i = 0; i < 60; i++) { + var pet = new Pet(UUID.randomUUID(), account, "cat", "Legacy " + i, PetSex.FEMALE); + current.add(pet); + ids.add(pet.id()); + } + assertTrue(current.beginSession()); + assertFalse(current.migrate()); + current.get(ids.getFirst()).bond(73); + assertTrue(current.save()); + assertTrue(Files.exists(directory.resolve("pets-recovery-required"))); + ready.set(true); + assertTrue(current.migrate()); + assertTrue(current.ownershipReady()); + for (UUID id : ids) assertEquals(character, current.get(id).ownerCharacterId()); + assertEquals(73, current.get(ids.getFirst()).bond()); + assertTrue(current.close()); + assertFalse(Files.exists(directory.resolve("pets-recovery-required"))); + var restored = store(); + assertTrue(restored.load()); + assertTrue(restored.migrate()); + assertEquals(60, restored.all().size()); + for (UUID id : ids) assertEquals(character, restored.get(id).ownerCharacterId()); + assertEquals(73, restored.get(ids.getFirst()).bond()); + assertTrue(restored.close()); + assertFalse(Files.exists(directory.resolve("pets-recovery-required"))); + } finally { + org.mockbukkit.mockbukkit.MockBukkit.unmock(); + } + } + + @Test + void nullKennelRootCanGainAHouseAndCloseWithoutLosingUnknownNullMetadata() throws Exception { + Files.writeString(directory.resolve("pets.yml"), + "version: 1\npets: {}\nkennels: null\nfuture-root: {kept: null, value: unchanged}\n"); + var current = store(); + assertTrue(current.load()); + assertTrue(current.beginSession()); + UUID account = UUID.randomUUID(), character = UUID.randomUUID(); + current.kennel("unloaded,1,2,3", account, character); + assertTrue(current.save()); + assertTrue(current.close()); + assertFalse(Files.exists(directory.resolve("pets-recovery-required"))); + var restored = store(); + assertTrue(restored.load()); + assertEquals(account, restored.kennelOwner("unloaded,1,2,3")); + assertEquals(character, restored.kennelCharacter("unloaded,1,2,3")); + var raw = net.tfminecraft.companionpets.ownership.OwnershipMigration.raw( + Files.readString(directory.resolve("pets.yml"))); + var unknown = (Map) raw.get("future-root"); + assertTrue(unknown.containsKey("kept")); + assertNull(unknown.get("kept")); + assertEquals("unchanged", unknown.get("value")); + assertTrue(restored.close()); + } + + @Test void sourcePresenceChangesRemainHeldUntilReviewedReloadAndNeverOverwriteExternalBytes() throws Exception { + var absent=store();assertTrue(absent.load());String external="pets: {}\nexternal: preserved\n";Files.writeString(directory.resolve("pets.yml"),external); + assertFalse(absent.save());assertTrue(absent.persistenceConflict());assertEquals(external,Files.readString(directory.resolve("pets.yml"))); + var known=store();assertTrue(known.load());Files.delete(directory.resolve("pets.yml")); + assertFalse(known.save());assertTrue(known.persistenceConflict());assertFalse(Files.exists(directory.resolve("pets.yml"))); + Files.writeString(directory.resolve("pets.yml"),external);assertFalse(known.save());assertEquals(external,Files.readString(directory.resolve("pets.yml"))); + assertTrue(known.load());assertTrue(known.save()); + } + @Test void duplicateKennelsAndMalformedSchemaCannotCollapseOrRewriteRawSource() throws Exception { + String one=UUID.randomUUID().toString(),two=UUID.randomUUID().toString(); + for(String secondOwner:List.of(one,two)) { + String original="pets: {}\nkennels:\n- {world: missing, x: 1, y: 2, z: 3, owner: "+one+", metadata: first}\n- {world: missing, x: 1, y: 2, z: 3, owner: "+secondOwner+", metadata: second}\n"; + Files.writeString(directory.resolve("pets.yml"),original);var current=store();assertFalse(current.load());assertFalse(current.save()); + assertEquals(original,Files.readString(directory.resolve("pets.yml"))); + } + for(String version:List.of("'2'","2.5","null")) { + String original="identity-version: "+version+"\npets: {}\nkennels: []\n";Files.writeString(directory.resolve("pets.yml"),original); + var current=store();assertFalse(current.load());assertFalse(current.save());assertEquals(original,Files.readString(directory.resolve("pets.yml"))); + } + } + @Test void ownershipPublicationRequiresAtomicMoveAndOrdinarySaveRetainsItsEstablishedFallback() throws Exception { + var current=store();assertTrue(current.load());var pet=net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(),UUID.randomUUID(),"wolf","Before",PetSex.MALE);current.add(pet);assertTrue(current.save()); + String before=Files.readString(directory.resolve("pets.yml"));assertTrue(current.close()); + current=store();assertTrue(current.load());current.get(pet.id()).name("After"); + writerWithMoves(current, call -> { + if (call.getArgument(1, Path.class).equals(directory.resolve("pets.yml"))) { + var options = (CopyOption[]) call.getRawArguments()[2]; + if (java.util.Arrays.asList(options).contains(StandardCopyOption.ATOMIC_MOVE)) + throw new AtomicMoveNotSupportedException("source", "target", "fixture"); + } + return call.callRealMethod(); + }); + assertFalse(current.saveOwnership()); + assertEquals(before, Files.readString(directory.resolve("pets.yml"))); + assertTrue(current.save()); + assertTrue(Files.readString(directory.resolve("pets.yml")).contains("After")); + assertTrue(current.close()); + } + + @Test void legacyCarersAreInertUntilReadyAndLiveMigrationRetainsTheExistingPetObject() throws Exception { + var server=org.mockbukkit.mockbukkit.MockBukkit.mock(); + var plugin=net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); + try { + UUID id=UUID.randomUUID(),account=UUID.randomUUID(); + Files.writeString(directory.resolve("pets.yml"),"pets:\n "+id+":\n owner: "+account+"\n type: wolf\n carers:\n "+account+": {trust: 14}\n friends: {future-id: {future-field: null}}\nkennels: [{world: missing, x: 1, y: 2, z: 3, owner: "+account+"}]\n"); + var current=store();assertTrue(current.load());Pet existing=current.get(id);assertTrue(existing.carers().entries().isEmpty());assertTrue(current.save()); + assertNotNull(net.tfminecraft.companionpets.ownership.OwnershipMigration.read(directory.resolve("pets.yml")).get("pets."+id+".carers."+account)); + assertTrue(current.beginSession());existing.ownershipHeld(true);existing.bond(59);current.requestSave();current.flush();assertTrue(current.migrate()); + assertEquals(59,net.tfminecraft.companionpets.ownership.OwnershipMigration.read(directory.resolve("pets.yml")).getDouble("pets."+id+".bond")); + assertSame(existing,current.get(id));assertTrue(existing.ownershipHeld());existing.ownershipHeld(false); + assertEquals(account,existing.ownerCharacterId());assertEquals(14,existing.carers().trust(account)); + assertTrue(current.unassigned().isEmpty());current.holdKennel("missing,1,2,3",true);existing.ownershipHeld(true);assertEquals(2,current.unassigned().size()); + assertTrue(current.close()); + } finally { org.mockbukkit.mockbukkit.MockBukkit.unmock(); } + } + @Test void externalWriterConflictAndInvalidMigrationFreezeSavingWithoutOverwritingEvidence() throws Exception { + var server=org.mockbukkit.mockbukkit.MockBukkit.mock();var plugin=net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); + try { + var current=store();assertTrue(current.load());var pet=net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(),UUID.randomUUID(),"wolf","Toby",PetSex.MALE);current.add(pet);assertTrue(current.save()); + String external=Files.readString(directory.resolve("pets.yml"))+"external-progress: preserved\n";Files.writeString(directory.resolve("pets.yml"),external); + assertFalse(current.save());assertTrue(current.persistenceConflict());assertTrue(pet.ownershipHeld());assertEquals(external,Files.readString(directory.resolve("pets.yml"))); + Files.writeString(directory.resolve("pets.yml"),"identity-version: 99\npets: {}\n");assertFalse(current.migrate());assertFalse(current.save()); + org.bukkit.Bukkit.getServicesManager().unregisterAll(plugin);assertFalse(current.migrate()); + } finally { org.mockbukkit.mockbukkit.MockBukkit.unmock(); } + } + + @Test void inertUnsupportedRelationshipPayloadsCannotBeOverwrittenByNewGameplayMemories() throws Exception { + UUID id=UUID.randomUUID(),account=UUID.randomUUID(),character=UUID.randomUUID(),carer=UUID.randomUUID(),friend=UUID.randomUUID(); + String original="identity-version: 2\npets:\n "+id+":\n owner: "+account+"\n owner-character: "+character+"\n type: wolf\n carers-characters: true\n carers: {"+carer+": future-format}\n friends: {"+friend+": null}\nkennels: []\n"; + Files.writeString(directory.resolve("pets.yml"),original);var current=store();assertTrue(current.load());var pet=current.get(id); + pet.carers().reinforce(carer,20,1000,0);pet.friends().reinforce(friend,20,1000,0);pet.bond(77);assertTrue(current.save());assertTrue(current.close()); + var restarted=store();assertTrue(restarted.load());assertEquals(77,restarted.get(id).bond());assertTrue(restarted.get(id).carers().entries().isEmpty());assertTrue(restarted.get(id).friends().entries().isEmpty()); + var raw=net.tfminecraft.companionpets.ownership.OwnershipMigration.raw(Files.readString(directory.resolve("pets.yml"))); + var row=(Map)((Map)raw.get("pets")).get(id.toString());assertEquals("future-format",((Map)row.get("carers")).get(carer.toString())); + var friends=(Map)row.get("friends");assertTrue(friends.containsKey(friend.toString()));assertNull(friends.get(friend.toString())); + } + @Test void unknownNullFieldsAndRelationshipMetadataSurviveGameplaySaveAndRestart() throws Exception { + UUID id=UUID.randomUUID(), account=UUID.randomUUID(), character=UUID.randomUUID(), carer=UUID.randomUUID(), friend=UUID.randomUUID(); + String text="future-root: {kept: null}\nidentity-version: 2\npets:\n "+id+":\n owner: "+account+"\n owner-character: "+character + +"\n type: missing-config\n future: {kept: null}\n carers-characters: true\n carers:\n "+carer+": {trust: 20, unknown: null}\n friends:\n "+friend + +": {trust: 15, unknown: null}\n words: [{word: sit, trick: SIT, unknown: null}, {word: future, trick: FUTURE, unknown: null}]\n carried-toy: STICK\nkennels: [{world: missing, x: 1, y: 2, z: 3, owner: "+account+", owner-character: "+character+", unknown: null}]\n"; + Files.writeString(directory.resolve("pets.yml"),text); + var first=store();assertTrue(first.load());first.get(id).bond(73);first.get(id).carriedToy(null);assertTrue(first.save()); + var second=store();assertTrue(second.load());second.get(id).need(Need.HUNGER,42);assertTrue(second.save()); + var raw=net.tfminecraft.companionpets.ownership.OwnershipMigration.raw(Files.readString(directory.resolve("pets.yml"))); + assertTrue(((Map)raw.get("future-root")).containsKey("kept")); + var row=(Map)((Map)raw.get("pets")).get(id.toString());assertFalse(row.containsKey("carried-toy")); + assertTrue(((Map)row.get("future")).containsKey("kept")); + for(String key:List.of("carers","friends")) { + var memory=(Map)((Map)row.get(key)).values().iterator().next();assertTrue(memory.containsKey("unknown"));assertNull(memory.get("unknown")); + } + var words=(List)row.get("words");assertEquals(2,words.size());assertTrue(((Map)words.getFirst()).containsKey("unknown")); + assertTrue(((Map)((List)raw.get("kennels")).getFirst()).containsKey("unknown")); + assertEquals(73,second.get(id).bond());assertEquals(42,second.get(id).need(Need.HUNGER)); + } + @Test void capturedSnapshotCanBeWrittenOnAnotherThreadAfterLiveRecordsChange() throws Exception { var store = store(); assertTrue(store.load()); - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Before", PetSex.FEMALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Before", PetSex.FEMALE); var friend = UUID.randomUUID(); pet.carers().reinforce(friend, 20, 1000, 0); pet.bindWord("sit", Trick.SIT); pet.progress(Trick.SIT, 40); @@ -106,26 +307,26 @@ private PetStore store() { @Test void iterationSnapshotsStaySafeAcrossAddReplaceRemoveAndReload() { var store = store(); assertTrue(store.load()); - var first = new Pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Luna", PetSex.FEMALE); + var first = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Luna", PetSex.FEMALE); store.add(first); var before = store.all(); assertThrows(UnsupportedOperationException.class, () -> before.clear()); - var second = new Pet(UUID.randomUUID(), first.ownerId(), "cat", "Sol", PetSex.MALE); + var second = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), first.ownerId(), "cat", "Sol", PetSex.MALE); for (Pet pet : before) store.add(second); assertEquals(java.util.List.of(first), before); assertEquals(2, store.all().size()); - var replacement = new Pet(first.id(), first.ownerId(), "cat", "Luna II", PetSex.FEMALE); + var replacement = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(first.id(), first.ownerId(), "cat", "Luna II", PetSex.FEMALE); store.add(replacement); assertTrue(store.all().contains(replacement)); assertFalse(store.all().contains(first)); var deleting = store.all(); for (Pet pet : deleting) assertTrue(store.remove(pet.id())); assertEquals(2, deleting.size()); assertTrue(store.all().isEmpty()); assertTrue(store.save()); assertTrue(store.load()); assertTrue(store.all().isEmpty()); - store.add(new Pet(UUID.randomUUID(), first.ownerId(), "cat", "New", PetSex.MALE)); + store.add(net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), first.ownerId(), "cat", "New", PetSex.MALE)); assertEquals(1, store.all().size()); assertTrue(store.load()); assertTrue(store.all().isEmpty()); } @Test void pendingChangesAreWrittenInTheBackgroundLikeADirectSave() throws Exception { var store = store(); assertTrue(store.load()); - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Luna", PetSex.FEMALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Luna", PetSex.FEMALE); pet.bindWord("sit", Trick.SIT); pet.progress(Trick.SIT, 40); pet.favoriteToy("STICK"); pet.carers().restore(UUID.randomUUID(), new net.tfminecraft.companionpets.pet.RelationshipMemory.Memory(12, 1, 2, 3)); store.add(pet); @@ -148,8 +349,8 @@ private PetStore store() { @Test void activeOwnerAndBodyLookupsFollowRecordChanges() { var store = store(); assertTrue(store.load()); var owner = UUID.randomUUID(); var body = UUID.randomUUID(); - var out = new Pet(UUID.randomUUID(), owner, "cat", "Luna", PetSex.FEMALE); - var saved = new Pet(UUID.randomUUID(), owner, "cat", "Sol", PetSex.MALE); saved.stored(true); + var out = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner, "cat", "Luna", PetSex.FEMALE); + var saved = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner, "cat", "Sol", PetSex.MALE); saved.stored(true); store.add(out); store.add(saved); assertEquals(java.util.List.of(out), java.util.List.copyOf(store.active())); assertEquals(1, store.countOut(owner)); assertEquals(2, store.countPets(owner)); @@ -170,7 +371,7 @@ private PetStore store() { @Test void carersAndPetFriendsSurviveRestartWithoutBecomingOwners() throws Exception { var first = store(); assertTrue(first.load()); - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Luna", PetSex.FEMALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Luna", PetSex.FEMALE); var carer = UUID.randomUUID(); var friend = UUID.randomUUID(); pet.carers().reinforce(carer, 20, 1000, 0); pet.carers().greeted(carer, 2000); pet.friends().reinforce(friend, 12, 3000, 0); first.add(pet); assertTrue(first.save()); @@ -187,7 +388,7 @@ private PetStore store() { @Test void explicitStayAndLayPersistAsOrdersAcrossRestart() { var first = store(); assertTrue(first.load()); for (PetOrder order : new PetOrder[]{PetOrder.STAY, PetOrder.LAY}) { - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", order.name(), PetSex.MALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", order.name(), PetSex.MALE); pet.order(order); first.add(pet); } assertTrue(first.save()); var restored = store(); assertTrue(restored.load()); @@ -200,7 +401,7 @@ void savesPetsAndKennelsAcrossRestart() { UUID id = UUID.randomUUID(); PetStore first = store(); assertTrue(first.load()); - Pet pet = new Pet(id, owner, "beagle", "Luna", PetSex.FEMALE); + Pet pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(id, owner, "beagle", "Luna", PetSex.FEMALE); pet.personality(PetPersonality.PLAYFUL); pet.bornAt(123456L); pet.lastOwnerNearbyMillis(123000L); @@ -264,7 +465,7 @@ void outsidePetKeepsItsIdentityAndLastPositionAcrossRepeatedRestarts() { UUID entity = UUID.randomUUID(); PetStore current = store(); assertTrue(current.load()); - Pet pet = new Pet(id, owner, "wolf", "Outside", PetSex.MALE); + Pet pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(id, owner, "wolf", "Outside", PetSex.MALE); pet.place("world", -17.25, 81, 35.5, 120); pet.entityId(entity); pet.bindWord("sit", Trick.SIT); @@ -294,7 +495,7 @@ void requiresOperatorRestoreAndPreservesBothFilesOnLoadFailure() throws Exceptio UUID id = UUID.randomUUID(); PetStore first = store(); assertTrue(first.load()); - first.add(new Pet(id, UUID.randomUUID(), "wolf", "Rex", PetSex.MALE)); + first.add(net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(id, UUID.randomUUID(), "wolf", "Rex", PetSex.MALE)); first.save(); Path primary = directory.resolve("pets.yml"); Path backup = directory.resolve("pets.yml.bak"); @@ -323,7 +524,7 @@ void damagedPrimaryDoesNotRevertOwnershipFromAnOlderBackup() throws Exception { UUID newOwner = UUID.randomUUID(); PetStore current = store(); assertTrue(current.load()); - Pet pet = new Pet(id, previousOwner, "wolf", "Rex", PetSex.MALE); + Pet pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(id, previousOwner, "wolf", "Rex", PetSex.MALE); current.add(pet); current.save(); pet.ownerId(newOwner); @@ -342,7 +543,7 @@ void missingPrimaryDoesNotResurrectDeletedPetsOrStartWithEmptyData() throws Exce UUID id = UUID.randomUUID(); PetStore current = store(); assertTrue(current.load()); - current.add(new Pet(id, UUID.randomUUID(), "wolf", "Rex", PetSex.MALE)); + current.add(net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(id, UUID.randomUUID(), "wolf", "Rex", PetSex.MALE)); current.save(); current.remove(id); current.save(); @@ -365,7 +566,7 @@ void backupKeepsPreviousCommittedSave() throws Exception { UUID id = UUID.randomUUID(); PetStore current = store(); assertTrue(current.load()); - Pet pet = new Pet(id, UUID.randomUUID(), "wolf", "Old name", PetSex.MALE); + Pet pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(id, UUID.randomUUID(), "wolf", "Old name", PetSex.MALE); current.add(pet); current.save(); pet.name("New name"); @@ -380,7 +581,7 @@ void deletionSurvivesInterruptionBeforeSnapshotSaveAndOlderBackupRestore() throw UUID id = UUID.randomUUID(); PetStore current = store(); assertTrue(current.load()); - current.add(new Pet(id, UUID.randomUUID(), "wolf", "Rex", PetSex.MALE)); + current.add(net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(id, UUID.randomUUID(), "wolf", "Rex", PetSex.MALE)); assertTrue(current.save()); String stalePrimary = Files.readString(directory.resolve("pets.yml")); @@ -394,7 +595,7 @@ void deletionSurvivesInterruptionBeforeSnapshotSaveAndOlderBackupRestore() throw Files.copy(directory.resolve("pets.yml.bak"), directory.resolve("pets.yml"), StandardCopyOption.REPLACE_EXISTING); assertNull(storePet(id)); assertThrows(IllegalArgumentException.class, - () -> restarted.add(new Pet(id, UUID.randomUUID(), "wolf", "Rex", PetSex.MALE))); + () -> restarted.add(net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(id, UUID.randomUUID(), "wolf", "Rex", PetSex.MALE))); } @Test @@ -403,7 +604,7 @@ void snapshotBackupFailureKeepsDurableDeletionAndShutdownGuard() throws Exceptio PetStore current = store(); assertTrue(current.load()); assertTrue(current.beginSession()); - current.add(new Pet(id, UUID.randomUUID(), "wolf", "Rex", PetSex.MALE)); + current.add(net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(id, UUID.randomUUID(), "wolf", "Rex", PetSex.MALE)); assertTrue(current.save()); String stalePrimary = Files.readString(directory.resolve("pets.yml")); Path blockedBackup = directory.resolve("pets.yml.bak.tmp"); @@ -427,7 +628,7 @@ void deletionLogFailureRefusesReleaseAndBlocksRecoveryAfterRestart() throws Exce PetStore current = store(); assertTrue(current.load()); assertTrue(current.beginSession()); - current.add(new Pet(id, UUID.randomUUID(), "wolf", "Rex", PetSex.MALE)); + current.add(net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(id, UUID.randomUUID(), "wolf", "Rex", PetSex.MALE)); assertTrue(current.save()); Files.createDirectory(directory.resolve("pet-deletions.log")); @@ -463,7 +664,7 @@ void cleanSessionShutdownAllowsRepeatedRestarts() { UUID id = UUID.randomUUID(); PetStore current = store(); assertTrue(current.load()); - current.add(new Pet(id, UUID.randomUUID(), "wolf", "Rex", PetSex.MALE)); + current.add(net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(id, UUID.randomUUID(), "wolf", "Rex", PetSex.MALE)); for (int restart = 0; restart < 3; restart++) { assertTrue(current.beginSession()); assertTrue(current.close()); @@ -530,7 +731,7 @@ void refusesToOverwriteMalformedRecordsWithoutValidBackup() throws Exception { @Test void kennelWorldNamesContainingCommasRoundTripWithoutBlockingPetSaves() { var current = store(); assertTrue(current.load()); - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Luna", PetSex.FEMALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Luna", PetSex.FEMALE); current.add(pet); String key = PetStore.kennelKey("world,chapter,two", -3, 65, 7); current.kennel(key, pet.ownerId()); @@ -551,7 +752,7 @@ void refusesToOverwriteMalformedRecordsWithoutValidBackup() throws Exception { @Test void backgroundProviderFailureKeepsChangesPendingForRetry() throws Exception { var current = store(); assertTrue(current.load()); - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Pending", PetSex.FEMALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Pending", PetSex.FEMALE); current.add(pet); AtomicBoolean unavailable = new AtomicBoolean(true); writerWithMoves(current, call -> { @@ -578,7 +779,7 @@ void refusesToOverwriteMalformedRecordsWithoutValidBackup() throws Exception { @Test void atomicMoveFallbackCommitsSnapshotsAndKeepsThePreviousBackup() throws Exception { var current = store(); assertTrue(current.load()); - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "First", PetSex.FEMALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "First", PetSex.FEMALE); current.add(pet); AtomicInteger fallbacks = new AtomicInteger(); writerWithMoves(current, call -> { @@ -605,7 +806,7 @@ void refusesToOverwriteMalformedRecordsWithoutValidBackup() throws Exception { var current = store(); assertTrue(current.load()); assertTrue(current.beginSession()); - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Interrupted", PetSex.MALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Interrupted", PetSex.MALE); current.add(pet); CountDownLatch writing = new CountDownLatch(1); CountDownLatch allowWrite = new CountDownLatch(1); @@ -645,7 +846,7 @@ void refusesToOverwriteMalformedRecordsWithoutValidBackup() throws Exception { var current = store(); assertTrue(current.load()); assertTrue(current.beginSession()); - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Saved", PetSex.MALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Saved", PetSex.MALE); current.add(pet); Path marker = directory.resolve("pets-recovery-required"); Files.delete(marker); @@ -668,7 +869,7 @@ void refusesToOverwriteMalformedRecordsWithoutValidBackup() throws Exception { Path folder = directory.resolve(background ? "background" : "immediate"); var current = new PetStore(folder.resolve("pets.yml").toFile(), quietLogger()); assertTrue(current.load()); - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Retry", PetSex.FEMALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Retry", PetSex.FEMALE); current.add(pet); ExecutorService executor = Executors.newSingleThreadExecutor(); ExecutorService boundary = mock(ExecutorService.class, delegatesTo(executor)); @@ -710,7 +911,7 @@ void refusesToOverwriteMalformedRecordsWithoutValidBackup() throws Exception { }); var current = new PetStore(directory.resolve("pets.yml").toFile(), logger); assertTrue(current.load()); - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Retained", PetSex.FEMALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Retained", PetSex.FEMALE); current.add(pet); Path temp = directory.resolve("pets.yml.tmp"); Files.createDirectory(temp); @@ -831,7 +1032,7 @@ void malformedCoordinatesDoNotBlockSaveOrBackgroundFlush(String malformed, boole }); var current = new PetStore(directory.resolve("pets.yml").toFile(), logger); assertTrue(current.load()); - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Before", PetSex.FEMALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Before", PetSex.FEMALE); current.add(pet); String valid = PetStore.kennelKey("world,chapter,two", Integer.MIN_VALUE, 65, Integer.MAX_VALUE); current.kennel(valid, pet.ownerId()); @@ -882,7 +1083,7 @@ private static Stream malformedCoordinates() { @Test void malformedPublicKennelKeyDoesNotPreventValidDataFromPersisting() { var current = store(); assertTrue(current.load()); - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Preserved", PetSex.FEMALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "cat", "Preserved", PetSex.FEMALE); current.add(pet); String valid = PetStore.kennelKey("houses", -3, 65, 7); current.kennel(valid, pet.ownerId()); diff --git a/src/test/java/net/tfminecraft/companionpets/store/SnapshotReuseTest.java b/src/test/java/net/tfminecraft/companionpets/store/SnapshotReuseTest.java index 7724b65..1aabca1 100644 --- a/src/test/java/net/tfminecraft/companionpets/store/SnapshotReuseTest.java +++ b/src/test/java/net/tfminecraft/companionpets/store/SnapshotReuseTest.java @@ -25,7 +25,7 @@ class SnapshotReuseTest { @BeforeEach void setup() { Logger logger = Logger.getAnonymousLogger(); logger.setUseParentHandlers(false); store = new PetStore(directory.resolve("pets.yml").toFile(), logger); assertTrue(store.load()); - pet = new Pet(ID, OWNER, "wolf", "Toby", PetSex.MALE); + pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(ID, OWNER, "wolf", "Toby", PetSex.MALE); pet.personality(PetPersonality.FRIENDLY); pet.place("world", 1, 64, 3, 4); pet.entityId(BODY); pet.bornAt(10); pet.bond(20); pet.favoriteToy("STICK"); pet.carriedToy("BONE"); pet.bindWord("sit", Trick.SIT); pet.progress(Trick.SIT, 40); pet.markAnnouncedLow(Need.HUNGER); @@ -106,6 +106,7 @@ void everyPersistedMutationRebuildsItsRow(String path, Consumer mutation, O var covered = new HashSet(); persistedChanges().forEach(change -> covered.add(((String) change.get()[0]).split("\\.")[0])); covered.add("type"); + covered.add("owner-character"); covered.add("carers-characters"); assertEquals(covered, row().keySet()); assertEquals("wolf", row().get("type")); assertEquals(Set.of(ID.toString()), store.snapshot().pets().keySet()); @@ -155,7 +156,7 @@ void everyPersistedMutationRebuildsItsRow(String path, Consumer mutation, O @Test void replacingRemovingAndReloadingPetsForgetCachedRows() { var before = row(); - var replacement = new Pet(ID, NEXT_OWNER, "cat", "Replacement", PetSex.FEMALE); + var replacement = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(ID, NEXT_OWNER, "cat", "Replacement", PetSex.FEMALE); store.add(replacement); assertNotSame(before, row()); assertEquals("Replacement", row().get("name")); var replaced = row(); assertTrue(store.save()); assertTrue(store.load()); assertNotSame(replaced, row()); assertEquals("Replacement", row().get("name")); @@ -163,7 +164,7 @@ void everyPersistedMutationRebuildsItsRow(String path, Consumer mutation, O } @Test void changedRowsDoNotReplaceTheRowsOfOtherPets() { - var other = new Pet(UUID.randomUUID(), OWNER, "cat", "Other", PetSex.FEMALE); store.add(other); + var other = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), OWNER, "cat", "Other", PetSex.FEMALE); store.add(other); var before = store.snapshot(); pet.name("Rex"); var after = store.snapshot(); assertNotSame(before.pets().get(ID.toString()), after.pets().get(ID.toString())); assertSame(before.pets().get(other.id().toString()), after.pets().get(other.id().toString())); diff --git a/src/test/java/net/tfminecraft/companionpets/testutil/OwnershipFixture.java b/src/test/java/net/tfminecraft/companionpets/testutil/OwnershipFixture.java new file mode 100644 index 0000000..a3deff2 --- /dev/null +++ b/src/test/java/net/tfminecraft/companionpets/testutil/OwnershipFixture.java @@ -0,0 +1,55 @@ +package net.tfminecraft.companionpets.testutil; + +import java.util.*; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.ServicePriority; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.plugin.PluginMock; +import net.tfminecraft.companionpets.pet.*; +import net.tfminecraft.tlibs.character.*; + +/** Existing behaviour fixtures use one explicit character per account; isolation tests supply distinct IDs. */ +public final class OwnershipFixture { + private OwnershipFixture() { } + public static PluginMock plugin() { return plugin("MockPlugin"); } + public static PluginMock plugin(String name) { + PluginMock plugin = MockBukkit.createMockPlugin(name); + register(plugin); + return plugin; + } + public static void register(org.bukkit.plugin.Plugin plugin) { + if (Bukkit.getServicesManager().load(CharacterIdentity.class) != null) return; + Bukkit.getServicesManager().register(CharacterIdentity.class, new CharacterIdentity() { + public Optional currentSession(Player player) { + return player == null || !player.isOnline() ? Optional.empty() : Optional.of(new CharacterSession(new CharacterRef(player.getUniqueId(), player.getUniqueId()), 1)); + } + public boolean isCurrent(CharacterSession session) { + return session != null && currentSession(Bukkit.getPlayer(session.ref().ownerId())).filter(session::equals).isPresent(); + } + public Optional info(UUID id) { + if (id == null) return Optional.empty(); + String name = Bukkit.getOfflinePlayer(id).getName(); + return Optional.of(new CharacterInfo(new CharacterRef(id, id), name == null ? "Unknown player" : name, CharacterStatus.ALIVE, null, Set.of(), 0, null)); + } + public Optional> characters(UUID owner) { return Optional.of(List.of(info(owner).orElseThrow())); } + public Optional lastPlayed(UUID owner) { return info(owner); } + public Optional legacyHeir(UUID owner) { return Optional.of(new CharacterRef(owner, owner)); } + public boolean legacyHeirsReady() { return true; } + public List findByName(String name) { + var player = Bukkit.getOfflinePlayerIfCached(name); + return player == null ? List.of() : List.of(info(player.getUniqueId()).orElseThrow()); + } + public String displayName(Player player) { return player.getName(); } + }, plugin, ServicePriority.Normal); + } + public static T load(Class type) { + if (Bukkit.getPluginManager().getPlugin("TLibs") == null) plugin("TLibs"); + return MockBukkit.load(type); + } + public static Pet pet(UUID id, UUID account, String type, String name, PetSex sex) { + Pet pet = new Pet(id, account, type, name, sex); + pet.ownership(account, account); + return pet; + } +} diff --git a/src/test/java/net/tfminecraft/companionpets/text/PetTextsTest.java b/src/test/java/net/tfminecraft/companionpets/text/PetTextsTest.java index 343e6c3..4ebd565 100644 --- a/src/test/java/net/tfminecraft/companionpets/text/PetTextsTest.java +++ b/src/test/java/net/tfminecraft/companionpets/text/PetTextsTest.java @@ -50,7 +50,7 @@ void aConfiguredTypeMadeOnlyOfSeparatorsCanHatchAndOpenItsPetProfile() throws Ex PetActions actions = null; try { server.addSimpleWorld("world"); - var plugin = MockBukkit.createMockPlugin(); + var plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var owner = server.addPlayer(); var yaml = new YamlConfiguration(); yaml.loadFromString("pets: {'_-_': {entity: WOLF, egg: WOLF_SPAWN_EGG, sex: choose}}"); @@ -73,7 +73,7 @@ void aConfiguredTypeMadeOnlyOfSeparatorsCanHatchAndOpenItsPetProfile() throws Ex assertEquals(Material.WOLF_SPAWN_EGG, owner.getInventory().getItemInMainHand().getType()); assertEquals(1, owner.getInventory().getItemInMainHand().getAmount()); assertTrue(owner.nextMessage().contains("Hatching cancelled. Your egg is safe.")); - var pet = new Pet(UUID.randomUUID(), owner.getUniqueId(), "_-_", "Luna", PetSex.FEMALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), owner.getUniqueId(), "_-_", "Luna", PetSex.FEMALE); pet.stored(true); store.add(pet); actions.menus().openCare(owner, pet); @@ -178,7 +178,7 @@ record Reaction(String type, String normal, String devoted) { } @Test void trainingAndCareMessagesKeepThePetNameAndCorrectPronouns() { - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Lúna {owner}", PetSex.FEMALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "wolf", "Lúna {owner}", PetSex.FEMALE); assertEquals("Look at Lúna {owner} and say a command", PetTexts.trainingPrompt(pet)); assertEquals("Lúna {owner} whines and looks around for her owner", PetTexts.missesOwner(pet.name(), pet.sex())); assertEquals("Lúna {owner} leans into your hand, worn out from training. Let her rest a while", PetTexts.restingCheck(pet.name(), pet.sex())); diff --git a/src/test/java/net/tfminecraft/companionpets/training/DefaultTricksTest.java b/src/test/java/net/tfminecraft/companionpets/training/DefaultTricksTest.java index 74787d4..7b4631e 100644 --- a/src/test/java/net/tfminecraft/companionpets/training/DefaultTricksTest.java +++ b/src/test/java/net/tfminecraft/companionpets/training/DefaultTricksTest.java @@ -15,7 +15,7 @@ class DefaultTricksTest { private JavaPlugin plugin; private YamlConfiguration yaml; @BeforeEach void setup() throws Exception { - MockBukkit.mock(); plugin = MockBukkit.createMockPlugin(); + MockBukkit.mock(); plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); yaml = new YamlConfiguration(); yaml.loadFromString(""" pets: @@ -27,7 +27,7 @@ class DefaultTricksTest { """); } @AfterEach void teardown() { MockBukkit.unmock(); } - private Pet pet(String type) { return new Pet(UUID.randomUUID(), UUID.randomUUID(), type, "Toby", PetSex.MALE); } + private Pet pet(String type) { return net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), type, "Toby", PetSex.MALE); } @Test void everyOrdinaryPetStartsWithVisibleLearnedFollow() { var config = CompanionConfig.load(plugin, yaml); var pet = pet("wolf"); diff --git a/src/test/java/net/tfminecraft/companionpets/visual/PetCapabilitiesTest.java b/src/test/java/net/tfminecraft/companionpets/visual/PetCapabilitiesTest.java index 51c5d2b..e19ccc4 100644 --- a/src/test/java/net/tfminecraft/companionpets/visual/PetCapabilitiesTest.java +++ b/src/test/java/net/tfminecraft/companionpets/visual/PetCapabilitiesTest.java @@ -29,7 +29,7 @@ public void apply(Entity entity, PetTypeDef type) { } }; @BeforeEach void setup() throws Exception { - MockBukkit.mock(); plugin = MockBukkit.createMockPlugin(); + MockBukkit.mock(); plugin = net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var yaml = new YamlConfiguration(); yaml.loadFromString(""" pets: @@ -82,7 +82,7 @@ public void apply(Entity entity, PetTypeDef type) { } var key = new NamespacedKey(plugin, "pet"); var runtime = new PetRuntime(plugin, config, new PetStore(plugin), new Sessions(), new Bodies(plugin, key, visual), visual, key, new NamespacedKey(plugin, "toy")); - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "dog", "Toby", PetSex.MALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "dog", "Toby", PetSex.MALE); var type = config.type("dog"); long before = System.currentTimeMillis(); var missing = runtime.capabilities(type); @@ -133,7 +133,7 @@ private java.util.Map retries(PetRuntime runtime) throws Exception var key = new NamespacedKey(plugin, "pet"); var runtime = new PetRuntime(plugin, config, new PetStore(plugin), new Sessions(), new Bodies(plugin, key, visual), visual, key, new NamespacedKey(plugin, "toy")); - var pet = new Pet(UUID.randomUUID(), UUID.randomUUID(), "dog", "Toby", PetSex.MALE); + var pet = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), UUID.randomUUID(), "dog", "Toby", PetSex.MALE); assertFalse(runtime.behaves(pet, PetBehavior.GREETING_TAIL_WAG)); assertFalse(runtime.behaves(pet, PetBehavior.BELLY_RUB)); assertTrue(runtime.behaves(pet, PetBehavior.FETCH)); diff --git a/src/test/java/net/tfminecraft/companionpets/visual/PetVisualTickerTest.java b/src/test/java/net/tfminecraft/companionpets/visual/PetVisualTickerTest.java index 58b0c72..f2a00eb 100644 --- a/src/test/java/net/tfminecraft/companionpets/visual/PetVisualTickerTest.java +++ b/src/test/java/net/tfminecraft/companionpets/visual/PetVisualTickerTest.java @@ -36,7 +36,7 @@ class PetVisualTickerTest { assertEquals(Particle.SPLASH,particle); assertEquals(24,count); splashes++; } }; - server.addWorld(world); var owner=server.addPlayer(); var plugin=MockBukkit.createMockPlugin(); + server.addWorld(world); var owner=server.addPlayer(); var plugin=net.tfminecraft.companionpets.testutil.OwnershipFixture.plugin(); var yaml=new YamlConfiguration(); yaml.loadFromString(""" pets: dog: @@ -65,7 +65,7 @@ class PetVisualTickerTest { }; var key=new NamespacedKey(plugin,"pet"); store=new PetStore(plugin); assertTrue(store.load()); runtime=new PetRuntime(plugin,CompanionConfig.load(plugin,yaml),store,new Sessions(),new Bodies(plugin,key,visual),visual,key,new NamespacedKey(plugin,"toy")); - pet=new Pet(UUID.randomUUID(),owner.getUniqueId(),"dog","Toby",PetSex.MALE); + pet=net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(),owner.getUniqueId(),"dog","Toby",PetSex.MALE); pet.stored(false); store.add(pet); body=new ShakingWolf(server); body.teleport(new Location(world,0,64,0)); server.registerEntity(body); runtime.remember(pet,body); assertTrue(runtime.config().type("dog").appearance().modeled()); diff --git a/tools/ownership-rollback.py b/tools/ownership-rollback.py new file mode 100644 index 0000000..c617456 --- /dev/null +++ b/tools/ownership-rollback.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Stopped-server ownership rollback preview; conflicts never overwrite subsequent progress.""" +import argparse +import hashlib +import os +from pathlib import Path +import shutil +import yaml + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("data", type=Path, help="CompanionPets data directory") +parser.add_argument("plan", help="Migration plan name, such as plan-0001") +parser.add_argument("--apply", action="store_true", help="Apply only an exact, conflict-free rollback") +args = parser.parse_args() +if args.data.joinpath("pets-recovery-required").exists(): + parser.error("Stop the server cleanly first; pets-recovery-required exists") +folder = args.data / "ownership-migrations" +plan = yaml.safe_load((folder / (args.plan + ".yml")).read_text()) +source = args.data / "pets.yml" +archive = folder / (args.plan + ".original.yml") +hash_of = lambda p: hashlib.sha256(p.read_bytes()).hexdigest() +if hash_of(archive) != plan["before"]: + parser.error("Immutable migration source backup failed verification") +current = hash_of(source) +if current == plan["before"]: + print("Already at the archived source; no write required") + raise SystemExit(0) +if current != plan["after"]: + parser.error("CONFLICT: pets.yml changed after migration; preserve current pets/items/estates and reconcile manually") +print("No conflicts: would restore " + str(archive) + "; the stopped server also needs the matching old JAR") +if args.apply: + backup = source.with_name("pets.pre-rollback.yml") + if backup.exists(): + parser.error("Rollback preservation copy already exists; retain and review it first") + shutil.copy2(source, backup) + with backup.open("rb") as handle: + os.fsync(handle.fileno()) + temp = source.with_name("pets.rollback.new") + with temp.open("wb") as handle: + handle.write(archive.read_bytes()) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp, source) + descriptor = os.open(source.parent, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + print("Restored archived source; preserved current file at " + str(backup))