package com.xfestudio.xfeservermanager.core.rollback;
import com.xfestudio.xfeservermanager.api.world.WorldChangeRecord;
import com.xfestudio.xfeservermanager.api.world.WorldStatePayload;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Builds deterministic fixed-watermark previews and performs no mutations. */
public final class RollbackPlanner {
public static final int MAX_TEMPORARY_CHUNKS = 128;
private final RollbackJournalPort journal;
private final WorldAccessPort world;
private final Clock clock;
public RollbackPlanner(RollbackJournalPort journal, WorldAccessPort world, Clock clock) {
this.journal = Objects.requireNonNull(journal, "journal");
this.world = Objects.requireNonNull(world, "world");
this.clock = Objects.requireNonNull(clock, "clock");
}
public RollbackPlan preview(RollbackQuery query, boolean force, boolean loadUnloadedChunks,
boolean ownerAuthorized) {
if ((force || loadUnloadedChunks) && !ownerAuthorized) {
throw new SecurityException("force and temporary chunk loading are owner-only");
}
JournalSlice slice = journal.capture(query);
var ordered = slice.entries().stream().sorted(Comparator.comparingLong(JournalEntry::sequence).reversed()).toList();
var temporaryChunks = new HashSet<ChunkCoordinate>();
var virtualStates = new HashMap<ObjectCoordinate, WorldStatePayload>();
var items = new ArrayList<RollbackPlanItem>();
for (JournalEntry entry : ordered) {
items.add(assess(entry, force, loadUnloadedChunks, ownerAuthorized, temporaryChunks, virtualStates));
}
return new RollbackPlan(UUID.randomUUID(), slice.watermark(), clock.instant(), RollbackDirection.ROLLBACK,
force, loadUnloadedChunks, ownerAuthorized, items, slice.gaps());
}
public RollbackPlan previewRedo(RollbackOperation completedRollback, boolean force,
boolean loadUnloadedChunks, boolean ownerAuthorized) {
if (completedRollback.status() != RollbackStatus.COMPLETED
|| completedRollback.plan().direction() != RollbackDirection.ROLLBACK) {
throw new IllegalArgumentException("redo requires a completed rollback");
}
if ((force || loadUnloadedChunks) && !ownerAuthorized) throw new SecurityException("owner authorization required");
Set<UUID> applied = Set.copyOf(completedRollback.appliedEventIds());
var temporaryChunks = new HashSet<ChunkCoordinate>();
var virtualStates = new HashMap<ObjectCoordinate, WorldStatePayload>();
var items = completedRollback.plan().items().stream()
.filter(item -> applied.contains(item.entry().change().eventId()))
.sorted(Comparator.comparingLong(item -> item.entry().sequence()))
.map(item -> assessRedo(item.entry(), force, loadUnloadedChunks, ownerAuthorized,
temporaryChunks, virtualStates)).toList();
return new RollbackPlan(UUID.randomUUID(), completedRollback.plan().watermark(), clock.instant(),
RollbackDirection.REDO, force, loadUnloadedChunks, ownerAuthorized,
items, completedRollback.plan().gaps());
}
private RollbackPlanItem assess(JournalEntry entry, boolean force, boolean load, boolean ownerAuthorized,
Set<ChunkCoordinate> loads,
Map<ObjectCoordinate, WorldStatePayload> virtualStates) {
WorldChangeRecord change = entry.change();
return assessStates(entry, change.afterState(), change.beforeState(), force, load, ownerAuthorized,
loads, virtualStates);
}
private RollbackPlanItem assessRedo(JournalEntry entry, boolean force, boolean load, boolean ownerAuthorized,
Set<ChunkCoordinate> loads,
Map<ObjectCoordinate, WorldStatePayload> virtualStates) {
WorldChangeRecord change = entry.change();
return assessStates(entry, change.beforeState(), change.afterState(), force, load, ownerAuthorized,
loads, virtualStates);
}
private RollbackPlanItem assessStates(JournalEntry entry, WorldStatePayload expected, WorldStatePayload target,
boolean force, boolean load, boolean ownerAuthorized,
Set<ChunkCoordinate> loads,
Map<ObjectCoordinate, WorldStatePayload> virtualStates) {
WorldChangeRecord change = entry.change();
if (!change.restorable()) return item(entry, expected, target, PlanDisposition.NOT_RESTORABLE, "event was not captured as restorable");
if (requiresOwnerAuthorization(change) && !ownerAuthorized) {
return item(entry, expected, target, PlanDisposition.OWNER_AUTHORIZATION_REQUIRED,
"entity and item restoration requires owner authorization");
}
var compatibility = world.compatibility(change, target);
if (!compatibility.compatible()) return item(entry, expected, target, PlanDisposition.INCOMPATIBLE, compatibility.detail());
ChunkCoordinate chunk = ChunkCoordinate.of(change);
if (!world.isChunkLoaded(chunk.dimensionId(), chunk.x(), chunk.z())) {
if (!load) return item(entry, expected, target, PlanDisposition.UNLOADED, "chunk is not loaded");
loads.add(chunk);
if (loads.size() > MAX_TEMPORARY_CHUNKS) {
return item(entry, expected, target, PlanDisposition.CHUNK_LOAD_LIMIT, "temporary chunk limit exceeded");
}
virtualStates.put(ObjectCoordinate.of(change), target);
return item(entry, expected, target, PlanDisposition.READY_REQUIRES_CHUNK_LOAD, "temporary load required");
}
ObjectCoordinate object = ObjectCoordinate.of(change);
WorldStatePayload actual;
if (virtualStates.containsKey(object)) {
actual = virtualStates.get(object);
} else {
actual = world.currentState(change);
}
if (!Objects.equals(actual, expected)) {
if (force) virtualStates.put(object, target);
return item(entry, expected, target, PlanDisposition.CONFLICT,
force ? "current state differs; owner force is eligible to override" : "current state differs from journal after state");
}
virtualStates.put(object, target);
return item(entry, expected, target, PlanDisposition.READY, "");
}
private static boolean requiresOwnerAuthorization(WorldChangeRecord change) {
return switch (change.kind()) {
case ENTITY_SPAWN, ENTITY_DEATH, ENTITY_REMOVE, ITEM_DROP, ITEM_PICKUP, ITEM_DESPAWN -> true;
default -> false;
};
}
private static RollbackPlanItem item(JournalEntry entry, WorldStatePayload expected, WorldStatePayload target,
PlanDisposition disposition, String detail) {
return new RollbackPlanItem(entry, expected, target, disposition, detail);
}
record ChunkCoordinate(String dimensionId, int x, int z) {
static ChunkCoordinate of(WorldChangeRecord change) {
return new ChunkCoordinate(change.dimensionId(), Math.floorDiv(change.x(), 16), Math.floorDiv(change.z(), 16));
}
}
record ObjectCoordinate(String dimensionId, int x, int y, int z, String category) {
static ObjectCoordinate of(WorldChangeRecord change) {
String category = switch (change.kind()) {
case BLOCK_PLACE, BLOCK_BREAK, BLOCK_CHANGE, EXPLOSION -> "block";
case CONTAINER_CHANGE -> "container";
case ENTITY_SPAWN, ENTITY_DEATH, ENTITY_REMOVE -> "entity";
case ITEM_DROP, ITEM_PICKUP, ITEM_DESPAWN -> "item";
case GAP, ROLLBACK, REDO -> change.kind().name();
};
return new ObjectCoordinate(change.dimensionId(), change.x(), change.y(), change.z(), category);
}
}
}
package com.xfestudio.xfeservermanager.core.rollback;
import com.xfestudio.xfeservermanager.api.world.WorldChangeRecord;
import com.xfestudio.xfeservermanager.api.world.WorldStatePayload;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Builds deterministic fixed-watermark previews and performs no mutations. */
public final class RollbackPlanner {
public static final int MAX_TEMPORARY_CHUNKS = 128;
private final RollbackJournalPort journal;
private final WorldAccessPort world;
private final Clock clock;
public RollbackPlanner(RollbackJournalPort journal, WorldAccessPort world, Clock clock) {
this.journal = Objects.requireNonNull(journal, "journal");
this.world = Objects.requireNonNull(world, "world");
this.clock = Objects.requireNonNull(clock, "clock");
}
public RollbackPlan preview(RollbackQuery query, boolean force, boolean loadUnloadedChunks,
boolean ownerAuthorized) {
if ((force || loadUnloadedChunks) && !ownerAuthorized) {
throw new SecurityException("force and temporary chunk loading are owner-only");
}
JournalSlice slice = journal.capture(query);
var ordered = slice.entries().stream().sorted(Comparator.comparingLong(JournalEntry::sequence).reversed()).toList();
var temporaryChunks = new HashSet<ChunkCoordinate>();
var virtualStates = new HashMap<ObjectCoordinate, WorldStatePayload>();
var items = new ArrayList<RollbackPlanItem>();
for (JournalEntry entry : ordered) {
items.add(assess(entry, force, loadUnloadedChunks, ownerAuthorized, temporaryChunks, virtualStates));
}
return new RollbackPlan(UUID.randomUUID(), slice.watermark(), clock.instant(), RollbackDirection.ROLLBACK,
force, loadUnloadedChunks, ownerAuthorized, items, slice.gaps());
}
public RollbackPlan previewRedo(RollbackOperation completedRollback, boolean force,
boolean loadUnloadedChunks, boolean ownerAuthorized) {
if (completedRollback.status() != RollbackStatus.COMPLETED
|| completedRollback.plan().direction() != RollbackDirection.ROLLBACK) {
throw new IllegalArgumentException("redo requires a completed rollback");
}
if ((force || loadUnloadedChunks) && !ownerAuthorized) throw new SecurityException("owner authorization required");
Set<UUID> applied = Set.copyOf(completedRollback.appliedEventIds());
var temporaryChunks = new HashSet<ChunkCoordinate>();
var virtualStates = new HashMap<ObjectCoordinate, WorldStatePayload>();
var items = completedRollback.plan().items().stream()
.filter(item -> applied.contains(item.entry().change().eventId()))
.sorted(Comparator.comparingLong(item -> item.entry().sequence()))
.map(item -> assessRedo(item.entry(), force, loadUnloadedChunks, ownerAuthorized,
temporaryChunks, virtualStates)).toList();
return new RollbackPlan(UUID.randomUUID(), completedRollback.plan().watermark(), clock.instant(),
RollbackDirection.REDO, force, loadUnloadedChunks, ownerAuthorized,
items, completedRollback.plan().gaps());
}
private RollbackPlanItem assess(JournalEntry entry, boolean force, boolean load, boolean ownerAuthorized,
Set<ChunkCoordinate> loads,
Map<ObjectCoordinate, WorldStatePayload> virtualStates) {
WorldChangeRecord change = entry.change();
return assessStates(entry, change.afterState(), change.beforeState(), force, load, ownerAuthorized,
loads, virtualStates);
}
private RollbackPlanItem assessRedo(JournalEntry entry, boolean force, boolean load, boolean ownerAuthorized,
Set<ChunkCoordinate> loads,
Map<ObjectCoordinate, WorldStatePayload> virtualStates) {
WorldChangeRecord change = entry.change();
return assessStates(entry, change.beforeState(), change.afterState(), force, load, ownerAuthorized,
loads, virtualStates);
}
private RollbackPlanItem assessStates(JournalEntry entry, WorldStatePayload expected, WorldStatePayload target,
boolean force, boolean load, boolean ownerAuthorized,
Set<ChunkCoordinate> loads,
Map<ObjectCoordinate, WorldStatePayload> virtualStates) {
WorldChangeRecord change = entry.change();
if (!change.restorable()) return item(entry, expected, target, PlanDisposition.NOT_RESTORABLE, "event was not captured as restorable");
if (requiresOwnerAuthorization(change) && !ownerAuthorized) {
return item(entry, expected, target, PlanDisposition.OWNER_AUTHORIZATION_REQUIRED,
"entity and item restoration requires owner authorization");
}
var compatibility = world.compatibility(change, target);
if (!compatibility.compatible()) return item(entry, expected, target, PlanDisposition.INCOMPATIBLE, compatibility.detail());
ChunkCoordinate chunk = ChunkCoordinate.of(change);
if (!world.isChunkLoaded(chunk.dimensionId(), chunk.x(), chunk.z())) {
if (!load) return item(entry, expected, target, PlanDisposition.UNLOADED, "chunk is not loaded");
loads.add(chunk);
if (loads.size() > MAX_TEMPORARY_CHUNKS) {
return item(entry, expected, target, PlanDisposition.CHUNK_LOAD_LIMIT, "temporary chunk limit exceeded");
}
virtualStates.put(ObjectCoordinate.of(change), target);
return item(entry, expected, target, PlanDisposition.READY_REQUIRES_CHUNK_LOAD, "temporary load required");
}
ObjectCoordinate object = ObjectCoordinate.of(change);
WorldStatePayload actual;
if (virtualStates.containsKey(object)) {
actual = virtualStates.get(object);
} else {
actual = world.currentState(change);
}
if (!Objects.equals(actual, expected)) {
if (force) virtualStates.put(object, target);
return item(entry, expected, target, PlanDisposition.CONFLICT,
force ? "current state differs; owner force is eligible to override" : "current state differs from journal after state");
}
virtualStates.put(object, target);
return item(entry, expected, target, PlanDisposition.READY, "");
}
private static boolean requiresOwnerAuthorization(WorldChangeRecord change) {
return switch (change.kind()) {
case ENTITY_SPAWN, ENTITY_DEATH, ENTITY_REMOVE, ITEM_DROP, ITEM_PICKUP, ITEM_DESPAWN -> true;
default -> false;
};
}
private static RollbackPlanItem item(JournalEntry entry, WorldStatePayload expected, WorldStatePayload target,
PlanDisposition disposition, String detail) {
return new RollbackPlanItem(entry, expected, target, disposition, detail);
}
record ChunkCoordinate(String dimensionId, int x, int z) {
static ChunkCoordinate of(WorldChangeRecord change) {
return new ChunkCoordinate(change.dimensionId(), Math.floorDiv(change.x(), 16), Math.floorDiv(change.z(), 16));
}
}
record ObjectCoordinate(String dimensionId, int x, int y, int z, String category) {
static ObjectCoordinate of(WorldChangeRecord change) {
String category = switch (change.kind()) {
case BLOCK_PLACE, BLOCK_BREAK, BLOCK_CHANGE, EXPLOSION -> "block";
case CONTAINER_CHANGE -> "container";
case ENTITY_SPAWN, ENTITY_DEATH, ENTITY_REMOVE -> "entity";
case ITEM_DROP, ITEM_PICKUP, ITEM_DESPAWN -> "item";
case GAP, ROLLBACK, REDO -> change.kind().name();
};
return new ObjectCoordinate(change.dimensionId(), change.x(), change.y(), change.z(), category);
}
}
}