Skip to content

Commit f0f49d8

Browse files
ryanbarlow97claude
andauthored
fix: restore owned animals lost from the world save (#58)
* fix: restore owned animals lost from the world save A forced stop can kill Paper before it saves chunks, so an owned animal that moved since the last save disappears. The startup scan then deleted its row as a ghost, which also emptied /animals. Keep a serialized copy of each loaded owned animal (every minute, on unload, and at disable). When the scan confirms an owned animal is gone, respawn it from that copy with its UUID, name, coat, gear and Cooking record. Animals saved under a logged-out rider (playerdata RootVehicle) now count as present. Deliberate removals drop the copy; deaths delete it with the row. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: reset restore-lost-animals when husbandry.yml fails to load Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 857fff2 commit f0f49d8

13 files changed

Lines changed: 703 additions & 14 deletions

‎src/main/java/net/tfminecraft/cooking/husbandry/HusbandryConfig.java‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public final class HusbandryConfig {
4949
private static double geneticSlowdownDivisor = 1;
5050
private static double careInfluence = 0.02;
5151
private static String statsRevision = "1";
52+
private static boolean restoreLostAnimals = true;
5253
private static List<HusbandryAmountBand> amountBands = List.of();
5354
private static double mountSpeedMinPct = 0.40;
5455
private static double mountSpeedGeneticsPct = 0.30;
@@ -157,6 +158,14 @@ public static void setStatsRevision(String revision) {
157158
statsRevision = revision == null ? "" : revision.trim();
158159
}
159160

161+
public static void setRestoreLostAnimals(boolean enabled) {
162+
restoreLostAnimals = enabled;
163+
}
164+
165+
public static boolean restoreLostAnimals() {
166+
return restoreLostAnimals;
167+
}
168+
160169
public static void setProfessionExp(String profession, HusbandryExpBracket bracket) {
161170
professionId = profession == null ? "" : profession.trim();
162171
defaultExp = bracket == null ? HusbandryExpBracket.of(6, 8) : bracket;

‎src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java‎

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import java.io.InputStream;
88
import java.nio.file.Files;
99
import java.util.HashMap;
10+
import java.util.HashSet;
1011
import java.util.List;
1112
import java.util.Map;
1213
import java.util.Set;
@@ -17,7 +18,8 @@
1718
import java.util.zip.InflaterInputStream;
1819

1920
/**
20-
* Reads saved entity chunks ({@code entities/r.X.Z.mca}) to find where unloaded animals are.
21+
* Reads saved entity chunks ({@code entities/r.X.Z.mca}) to find where unloaded animals are,
22+
* and player files for animals saved with a rider who logged out ({@code RootVehicle}).
2123
* Runs off the main thread; it only reads files.
2224
*/
2325
final class HusbandryEntityScan {
@@ -26,8 +28,11 @@ record WorldDir(String world, File entities) {}
2628

2729
record Found(String world, int x, int y, int z) {}
2830

29-
/** {@code complete} is false when some chunk could not be read, so a missing animal may still exist. */
30-
record Result(Map<UUID, Found> found, boolean complete) {}
31+
/**
32+
* {@code ridden} holds animals saved under a logged-out rider.
33+
* {@code complete} is false when some chunk or player file could not be read, so a missing animal may still exist.
34+
*/
35+
record Result(Map<UUID, Found> found, Set<UUID> ridden, boolean complete) {}
3136

3237
private static final Pattern REGION = Pattern.compile("r\\.(-?\\d{1,7})\\.(-?\\d{1,7})\\.mca");
3338
private static final int SECTOR = 4096;
@@ -37,10 +42,15 @@ record Result(Map<UUID, Found> found, boolean complete) {}
3742
private HusbandryEntityScan() {}
3843

3944
static Result scan(List<WorldDir> worlds, Set<UUID> targets) {
45+
return scan(worlds, null, targets);
46+
}
47+
48+
static Result scan(List<WorldDir> worlds, File playerdata, Set<UUID> targets) {
4049
Map<UUID, Found> found = new HashMap<>();
50+
Set<UUID> ridden = new HashSet<>();
4151
boolean complete = true;
4252
if (targets.isEmpty()) {
43-
return new Result(found, true);
53+
return new Result(found, ridden, true);
4454
}
4555
for (WorldDir world : worlds) {
4656
if (!world.entities().exists()) {
@@ -61,7 +71,31 @@ static Result scan(List<WorldDir> worlds, Set<UUID> targets) {
6171
complete &= scanRegion(world, file, regionX, regionZ, targets, found);
6272
}
6373
}
64-
return new Result(found, complete);
74+
complete &= scanPlayers(playerdata, targets, ridden);
75+
return new Result(found, ridden, complete);
76+
}
77+
78+
private static boolean scanPlayers(File playerdata, Set<UUID> targets, Set<UUID> ridden) {
79+
if (playerdata == null || !playerdata.exists()) {
80+
return true;
81+
}
82+
File[] files = playerdata.listFiles((dir, name) -> name.endsWith(".dat"));
83+
if (files == null) {
84+
return false;
85+
}
86+
boolean complete = true;
87+
for (File file : files) {
88+
try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(
89+
new GZIPInputStream(new ByteArrayInputStream(readBounded(file, MAX_CHUNK_BYTES)))
90+
.readNBytes(MAX_CHUNK_BYTES)))) {
91+
Map<UUID, Found> vehicle = new HashMap<>();
92+
readPlayer(in, targets, vehicle);
93+
ridden.addAll(vehicle.keySet());
94+
} catch (IOException | RuntimeException ex) {
95+
complete = false;
96+
}
97+
}
98+
return complete;
6599
}
66100

67101
private static boolean scanRegion(
@@ -179,6 +213,31 @@ private static void readEntityChunk(DataInputStream in, String world, Set<UUID>
179213
}
180214
}
181215

216+
private static void readPlayer(DataInputStream in, Set<UUID> targets, Map<UUID, Found> found)
217+
throws IOException {
218+
if (in.readByte() != COMPOUND) {
219+
return;
220+
}
221+
in.readUTF();
222+
byte type;
223+
while ((type = in.readByte()) != END) {
224+
String key = in.readUTF();
225+
if (type == COMPOUND && key.equals("RootVehicle")) {
226+
byte inner;
227+
while ((inner = in.readByte()) != END) {
228+
String innerKey = in.readUTF();
229+
if (inner == COMPOUND && innerKey.equals("Entity")) {
230+
readEntity(in, "", targets, found);
231+
} else {
232+
skip(in, inner);
233+
}
234+
}
235+
} else {
236+
skip(in, type);
237+
}
238+
}
239+
}
240+
182241
private static void readEntityList(DataInputStream in, String world, Set<UUID> targets, Map<UUID, Found> found)
183242
throws IOException {
184243
byte element = in.readByte();

‎src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
import org.bukkit.entity.Entity;
1414
import org.bukkit.entity.LivingEntity;
1515
import org.bukkit.event.EventHandler;
16+
import org.bukkit.event.EventPriority;
1617
import org.bukkit.event.Listener;
18+
import org.bukkit.event.entity.EntityRemoveEvent;
1719
import org.bukkit.event.world.EntitiesLoadEvent;
1820
import org.bukkit.event.world.EntitiesUnloadEvent;
1921

@@ -35,6 +37,26 @@ public void onEntitiesUnload(EntitiesUnloadEvent event) {
3537
}
3638
}
3739

40+
@EventHandler(priority = EventPriority.MONITOR)
41+
public void onEntityRemove(EntityRemoveEvent event) {
42+
handleRemove(event.getEntity().getUniqueId(), event.getCause());
43+
}
44+
45+
static void handleRemove(UUID uuid, EntityRemoveEvent.Cause cause) {
46+
if (HusbandrySnapshots.keepsSnapshot(cause) || HusbandryEntities.getLoaded(uuid).isEmpty()) {
47+
return;
48+
}
49+
HusbandryRepository repository = HusbandryEntities.repository();
50+
if (repository == null) {
51+
return;
52+
}
53+
try {
54+
repository.deleteSnapshot(uuid);
55+
} catch (SqliteDatabaseException ex) {
56+
Bukkit.getLogger().severe("[Cooking] Failed to drop the snapshot of " + uuid + ": " + ex.getMessage());
57+
}
58+
}
59+
3860
public static void applyStatsRevision() {
3961
HusbandryRepository repository = HusbandryEntities.repository();
4062
if (repository == null) {
@@ -100,10 +122,17 @@ public static void flushLoadedForDisable() {
100122
}
101123
long now = System.currentTimeMillis();
102124
List<HusbandryAnimal> toSave = new ArrayList<>(snapshot.size());
125+
Map<UUID, byte[]> copies = new HashMap<>();
103126
for (HusbandryAnimal animal : snapshot) {
104127
Entity entity = Bukkit.getEntity(animal.uuid());
105128
if (entity != null) {
106129
HusbandryLocation.remember(animal, entity);
130+
if (HusbandrySnapshots.shouldCapture(animal)) {
131+
byte[] copy = HusbandrySnapshots.capture(entity, false);
132+
if (copy != null) {
133+
copies.put(animal.uuid(), copy);
134+
}
135+
}
107136
}
108137
animal.setUnloadedAt(now);
109138
toSave.add(animal);
@@ -113,6 +142,7 @@ public static void flushLoadedForDisable() {
113142
} catch (SqliteDatabaseException ex) {
114143
Bukkit.getLogger().severe("[Cooking] Failed to flush husbandry animals on disable: " + ex.getMessage());
115144
}
145+
HusbandrySnapshots.save(repository, copies);
116146
}
117147

118148
static void handleLoad(Entity entity) {
@@ -185,5 +215,11 @@ static void handleUnload(Entity entity) {
185215
HusbandryLocation.remember(animal, entity);
186216
animal.setUnloadedAt(System.currentTimeMillis());
187217
repository.upsertAnimal(animal);
218+
if (HusbandrySnapshots.shouldCapture(animal)) {
219+
byte[] copy = HusbandrySnapshots.capture(entity, true);
220+
if (copy != null) {
221+
HusbandrySnapshots.save(repository, Map.of(uuid, copy));
222+
}
223+
}
188224
}
189225
}

‎src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLoader.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ public void load(File file) {
8282
config.getDouble("breeding.genetic-slowdown-divisor", 0.4),
8383
config.getDouble("breeding.care-influence", 0.02));
8484
HusbandryConfig.setStatsRevision(config.getString("stats-revision", "1"));
85+
HusbandryConfig.setRestoreLostAnimals(config.getBoolean("restore-lost-animals", true));
8586
HusbandryConfig.setProfessionExp(
8687
config.getString("profession", "farming"),
8788
parseExpBracket(config.getConfigurationSection("exp"), 6, 8));
@@ -329,6 +330,7 @@ private static void applyDefaults() {
329330
HusbandryConfig.setMountSpeedShares(0.40, 0.30, 0.20);
330331
HusbandryConfig.setBreeding(0.4, 0.4, 0.02);
331332
HusbandryConfig.setStatsRevision("1");
333+
HusbandryConfig.setRestoreLostAnimals(true);
332334
HusbandryConfig.setProfessionExp("farming", HusbandryExpBracket.of(6, 8));
333335
}
334336
}

‎src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java‎

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
/**
2323
* Finds owned animals sitting in unloaded chunks by reading the saved entity chunks,
2424
* so {@code /animals} can point at them. An owned animal that is not in any saved chunk
25-
* of a fully scanned world is a ghost: its row is deleted and logged.
25+
* of a fully scanned world, nor under a logged-out rider, was lost from the save: it is
26+
* respawned from its snapshot, or, without one, its row is deleted as a ghost and logged.
2627
*/
2728
public final class HusbandryLocator {
2829

@@ -40,6 +41,12 @@ static void markFound(UUID uuid) {
4041
}
4142
}
4243

44+
static void markMissing(UUID uuid) {
45+
if (uuid != null) {
46+
MISSING.add(uuid);
47+
}
48+
}
49+
4350
/** Scans once in the background. Call on the main thread after loaded worlds have resumed. */
4451
public static void scanUnloaded() {
4552
HusbandryRepository repository = HusbandryEntities.repository();
@@ -59,8 +66,9 @@ public static void scanUnloaded() {
5966
for (World world : Bukkit.getWorlds()) {
6067
worlds.add(new HusbandryEntityScan.WorldDir(world.getName(), entitiesFolder(world)));
6168
}
69+
File playerdata = new File(Bukkit.getWorlds().get(0).getWorldFolder(), "playerdata");
6270
Bukkit.getScheduler().runTaskAsynchronously(Cooking.plugin, () -> {
63-
HusbandryEntityScan.Result result = HusbandryEntityScan.scan(worlds, targets.keySet());
71+
HusbandryEntityScan.Result result = HusbandryEntityScan.scan(worlds, playerdata, targets.keySet());
6472
if (Cooking.plugin != null && Cooking.plugin.isEnabled()) {
6573
Bukkit.getScheduler().runTask(Cooking.plugin, () -> apply(targets, result, worlds));
6674
}
@@ -85,6 +93,7 @@ private static void apply(
8593
return;
8694
}
8795
int located = 0;
96+
int restoring = 0;
8897
int dropped = 0;
8998
int missing = 0;
9099
List<String> scannedWorlds = worlds.stream().map(HusbandryEntityScan.WorldDir::world).toList();
@@ -98,13 +107,31 @@ private static void apply(
98107
// Loaded or unloaded again during the scan; the stored location is newer.
99108
continue;
100109
}
110+
if (result.ridden().contains(uuid)) {
111+
// Saved with a rider who logged out; it comes back when they join.
112+
MISSING.remove(uuid);
113+
continue;
114+
}
101115
HusbandryEntityScan.Found found = result.found().get(uuid);
102116
if (found == null) {
103117
HusbandryAnimal animal = stored.get();
104118
// An animal last seen in a world that was not scanned may still be there.
105119
if (!isConfirmedGhost(result.complete(), animal.world(), scannedWorlds)) {
106120
continue;
107121
}
122+
try {
123+
if (HusbandrySnapshots.restore(repository, animal, repository.listOwners(uuid))) {
124+
MISSING.remove(uuid);
125+
restoring++;
126+
continue;
127+
}
128+
} catch (SqliteDatabaseException ex) {
129+
Bukkit.getLogger().severe("[Cooking] Failed to read the snapshot of " + uuid
130+
+ ": " + ex.getMessage());
131+
MISSING.add(uuid);
132+
missing++;
133+
continue;
134+
}
108135
try {
109136
String line = deleteGhost(repository, animal);
110137
Bukkit.getLogger().warning(line);
@@ -130,12 +157,12 @@ private static void apply(
130157
Bukkit.getLogger().severe("[Cooking] Failed to save husbandry animal location: " + ex.getMessage());
131158
}
132159
}
133-
if (located > 0 || dropped > 0 || missing > 0 || !result.complete()) {
160+
if (located > 0 || restoring > 0 || dropped > 0 || missing > 0 || !result.complete()) {
134161
String failed = missing > 0
135162
? ", " + missing + " still marked missing after a failed drop"
136163
: "";
137-
Bukkit.getLogger().info("[Cooking] Animal scan: updated " + located + " locations, dropped "
138-
+ dropped + " ghost animals" + failed
164+
Bukkit.getLogger().info("[Cooking] Animal scan: updated " + located + " locations, restoring "
165+
+ restoring + " lost animals from snapshots, dropped " + dropped + " ghost animals" + failed
139166
+ (result.complete() ? "." : " (some chunks could not be read, none dropped)."));
140167
}
141168
}
@@ -167,19 +194,23 @@ static String deleteGhost(HusbandryRepository repository, HusbandryAnimal animal
167194
}
168195

169196
static String ghostLog(HusbandryAnimal animal, List<HusbandryOwner> owners) {
170-
String name = animal.name() == null || animal.name().isBlank() ? "(unnamed)" : animal.name();
171-
String type = animal.type() == null || animal.type().isBlank() ? "unknown" : animal.type();
172197
String place = animal.hasLocation()
173198
? animal.world() + " " + animal.x() + ", " + animal.y() + ", " + animal.z()
174199
: "unknown";
200+
return "[Cooking] Dropped ghost animal " + describe(animal, owners) + " last seen " + place;
201+
}
202+
203+
/** Name, type, uuid and owners, as the ghost and restore log lines print them. */
204+
static String describe(HusbandryAnimal animal, List<HusbandryOwner> owners) {
205+
String name = animal.name() == null || animal.name().isBlank() ? "(unnamed)" : animal.name();
206+
String type = animal.type() == null || animal.type().isBlank() ? "unknown" : animal.type();
175207
String ownerText = owners == null || owners.isEmpty()
176208
? "none"
177209
: owners.stream()
178210
.sorted((left, right) -> left.playerUuid().compareTo(right.playerUuid()))
179211
.map(owner -> owner.playerUuid() + " (" + owner.role() + ")")
180212
.collect(Collectors.joining(", "));
181-
return "[Cooking] Dropped ghost animal " + name + " (" + type + ") " + animal.uuid()
182-
+ " owners=" + ownerText + " last seen " + place;
213+
return name + " (" + type + ") " + animal.uuid() + " owners=" + ownerText;
183214
}
184215

185216
private static boolean sameLocation(HusbandryAnimal animal, HusbandryEntityScan.Found found) {

0 commit comments

Comments
 (0)