crash recovery refunds only photos found in the saved data (async scan): after a crash that rolled the shot back to the last save the camera still had the film and the refund duplicated it, a failed scan refunds all, jobs stay in pending.yml until decided

This commit is contained in:
MineTec committed 2026-11-05 17:13:40 +01:00
1 parent 3a064a5ffa
commit bf192588a9
2 files changed
+81 -16

No files matched your search

@@ -18,6 +18,11 @@ public class JoinListener implements Listener {
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
player.discoverRecipes(SurvivalRecipes.allKeys());
giveRefunds(player);
}
/** Hands out the film owed to {@code player} (also used once the crash recovery has decided). */
static void giveRefunds(Player player) {
int films = Main.getInstance().getPendingPhotos().takeRefunds(player.getUniqueId());
for (int i = 0; i < films; i++) CameraItems.give(player, CameraItems.createFilm());
if (films > 0) {
@@ -2,11 +2,20 @@ package eu.mhsl.minecraft.pixelpics.survival;
import eu.mhsl.minecraft.pixelpics.Main;
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import eu.mhsl.minecraft.pixelpics.utils.MapUsageScan;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.plugin.IllegalPluginAccessException;
/**
* Persists photos that are still rendering ({@code pending.yml}), so a crash cannot swallow film: a job still listed
@@ -27,31 +36,78 @@ public final class PendingPhotos {
this.yml = YamlConfiguration.loadConfiguration(file);
}
/** Turns jobs left over from a crash into film refunds; their maps keep the blank placeholder. */
/**
* Turns jobs left over from a crash into film refunds; their maps keep the blank placeholder. A crash also rolls
* the world back to its last save: if that save predates the shot, the photo item is in no saved data and the
* camera still holds the film, so a refund would duplicate it. The saved worlds and player files are therefore
* scanned (off the main thread) and only photos found there are refunded; if the scan fails, all are refunded. The
* jobs stay in the file until decided, so another crash meanwhile loses nothing.
*/
public void recoverAbandoned() {
ConfigurationSection jobs = yml.getConfigurationSection(JOBS);
if (jobs == null) return;
int recovered = 0;
Map<Integer, UUID> owed = new LinkedHashMap<>();
for (String key : jobs.getKeys(false)) {
ConfigurationSection job = jobs.getConfigurationSection(key);
if (job == null) continue;
int mapId = Integer.parseInt(key);
if (MapManager.hasImage(mapId)) continue; // developed, only the bookkeeping was lost
int mapId;
try {
mapId = Integer.parseInt(key);
} catch (NumberFormatException e) {
continue;
}
if (job == null || MapManager.hasImage(mapId)) continue; // developed, only the bookkeeping was lost
if (!MapManager.isManaged(mapId)) MapManager.savePlaceholder(mapId);
String player = job.getString("player");
if (player != null && job.getBoolean("film")) {
addRefund(UUID.fromString(player));
recovered++;
if (player == null || !job.getBoolean("film")) continue;
try {
owed.put(mapId, UUID.fromString(player));
} catch (IllegalArgumentException ignored) {
// corrupt entry: nothing to refund
}
}
yml.set(JOBS, null);
save();
if (recovered > 0) {
Main.getInstance()
.getLogger()
.warning(recovered + " photo(s) did not develop before the last shutdown; their film is refunded"
+ " when the players join.");
if (owed.isEmpty()) {
yml.set(JOBS, null);
save();
return;
}
Main plugin = Main.getInstance();
List<Path> worlds = MapUsageScan.worldFolders();
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
Set<Integer> saved;
try {
saved = MapUsageScan.disk(worlds);
} catch (IOException | RuntimeException e) {
plugin.getLogger()
.warning("Could not scan the saved data for photos lost in the crash (" + e.getMessage()
+ "); refunding all of their film.");
saved = null;
}
Set<Integer> found = saved;
try {
Bukkit.getScheduler().runTask(plugin, () -> refundAbandoned(owed, found));
} catch (IllegalPluginAccessException ignored) {
// disabled meanwhile: the jobs are still in the file, decided at the next start
}
});
}
/** Refunds the abandoned photos whose item was saved ({@code saved} null = all). */
private void refundAbandoned(Map<Integer, UUID> owed, Set<Integer> saved) {
int refunded = 0;
for (Map.Entry<Integer, UUID> e : owed.entrySet()) {
if (saved == null || saved.contains(e.getKey())) {
addRefund(e.getValue(), false);
refunded++;
}
yml.set(JOBS + "." + e.getKey(), null);
}
save();
Main.getInstance()
.getLogger()
.warning(owed.size() + " photo(s) did not develop before the last shutdown; " + refunded
+ " film refunded when the players join, " + (owed.size() - refunded)
+ " not (the crash rolled the shot back: the photo was never saved).");
for (Player player : Bukkit.getOnlinePlayers()) JoinListener.giveRefunds(player);
}
public void add(int mapId, UUID player, boolean film) {
@@ -66,9 +122,13 @@ public final class PendingPhotos {
}
public void addRefund(UUID player) {
addRefund(player, true);
}
private void addRefund(UUID player, boolean save) {
String key = REFUNDS + "." + player;
yml.set(key, yml.getInt(key) + 1);
save();
if (save) save();
}
/** The films owed to {@code player}, now cleared. */