Initial minecraft-builder-mcp prototype and Gothic hall build
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# world-core
|
||||
|
||||
Java 17 world editing core with no Paper/Fabric dependency. It provides immutable plans, bounded preflight and write slices, optimistic conflict checks, confirmed write receipts, checked undo, and durable write-ahead intents. `RecipeCompiler` implements the version 1 `box`, `line`, `cylinder`, and nested `repeat` subset.
|
||||
|
||||
## Integration contract
|
||||
|
||||
One `EditEngine` belongs to one world and has one active operation at a time. `WorldAccess` must return canonical full state strings; the adapter supplies `BlockPolicy` and a `ContextGuard` that verifies the current project, region, world epoch, permissions, and chunk availability. Both original and desired states must be supported. The adapter must exclude inventories, entities, physics, and compound blocks whose secondary changes cannot be described by a single block write.
|
||||
|
||||
World access runs on the server thread. Use one IO executor for durable methods. No IO method holds the engine monitor while writing or syncing files, so status and cancellation remain responsive. The adapter must serialize IO and avoid staging/committing while its IO task is pending.
|
||||
|
||||
1. Server thread: `prepare(projectId, epoch, region, desired, dependencies)` captures a bounded snapshot. It does not write the world.
|
||||
2. IO executor: `persistPlan(plan.id())`.
|
||||
3. Server thread: `start(plan.id(), idempotencyKey)` creates an operation. Reusing the same key for the same immutable plan returns the original operation ID; another plan is rejected. Keys are scoped to a project.
|
||||
4. IO executor: `flushOperation(operationId)` before returning a durable operation ID.
|
||||
5. Server tick: `stageSlice(operationId)`. An empty result can mean preflight/final verification is still in progress. Check `status` and persist it if `needsFlush` is true. Preflight scans the whole request in bounded steps before any world write; changes can still arise after that scan.
|
||||
6. When a slice is returned, IO executor: `persistIntent(intent)`.
|
||||
7. Server thread: `commitSlice(intent)`. The engine rechecks the journaled before values, dependencies, context, and block policy after IO. It then writes and reads back at most the configured block limit, checking elapsed time between blocks.
|
||||
8. IO executor: `flushOperation(operationId)` before another slice. Repeat steps 5–8 until terminal. `APPLIED` follows a bounded final live verification.
|
||||
|
||||
If cancellation arrives while a state is being persisted, a newer cancellation flag leaves `needsFlush` true; flush it before another stage. Stop requests retain already confirmed changes. An intent is an internal object capability: the exact instance must pass through persistence and commit, and must never be deserialized from a remote request.
|
||||
|
||||
`prepareUndo` only includes confirmed actual writes. Blocks already at the desired state are skipped and never claimed as this operation's work. Undo rejects a changed current state and any known later operation at the same position, even if its current value happens to equal the older result. `receipts` is for adapter bookkeeping, such as named part masks; it is potentially large and should not be returned to the model.
|
||||
|
||||
Dependencies are explicitly supplied positions, limited separately from writes. Each slice rechecks all dependencies against their original values, or this operation's confirmed desired values when it has modified those positions. The engine does not infer structural supports from geometry. Final verification and slices are observations across ticks, not an atomic transaction for an entire building.
|
||||
|
||||
Use `pendingOperations()` for scheduling and cancellation loops: a maintained index contains only active operations and terminal operations still needing persistence, so each tick does not scan completed history. Use `recentOperations(limit)` (0–100) and `operationCount()` for bounded context; `operations()` is a full-history administrative snapshot. Recent operations are ordered by in-process creation, with persisted plan-capture time used as an approximation after restart. Status views never copy full receipts.
|
||||
|
||||
The adapter can call `recordExternal(position)` for observed successful external edits on the server thread. It invalidates known ownership even if a player changes a block away and back to the same state, so undo rejects that known later intervention. Do not call it for the engine's own writes or cancelled player events. Notifications allocate no history for unowned positions and do not write files in events. They are **ephemeral in this prototype**: after restart, unknown external edits retain only the live content-check guarantee. Persisted block-event revisions remain future journal work.
|
||||
|
||||
## Prototype journal and recovery
|
||||
|
||||
`JsonJournal` uses one checksummed JSON file per plan and operation, flushed file contents, atomic replacement, and directory fsync. This initial implementation deliberately uses atomic JSON snapshots instead of the design document's planned SQLite index and compressed chunk files. The host filesystem must support those durability primitives; there is no silent fallback to a weaker rename. Only one server process may own a journal directory.
|
||||
|
||||
Plans and idempotency records survive restart. Any queued/applying operation, unfinished intent, or previous uncertain result becomes `RECOVERY_REQUIRED` on load and blocks all new writes in that engine. No intent is replayed automatically. `inspectRecovery(id, offset, limit)` reads a bounded page of uncertain before/after/current states; matching after is evidence of content, not proof of authorship. Malformed or corrupt committed records fail startup. Uncommitted temporary files are ignored.
|
||||
|
||||
Recovery never resumes, overwrites, or deletes journal history automatically. An explicit administrator can call `reviewRecovery(id)` to inspect counts and a SHA-256 digest of current contents across the union of earlier confirmed receipts and the pending uncertain write mask. `abandonRecovery(id, expectedDigest)` rereads that same mask and refuses a stale digest. It leaves all world blocks untouched, marks the operation `FAILED`, permanently disables undo for that abandoned operation, and invalidates previous ownership on the affected positions. The invalidation revision is persisted and survives restart. This action abandons uncertain history; it is not a rollback or proof that previous writes happened.
|
||||
|
||||
The adapter must expose review and abandonment exclusively through an explicit administrative interface, outside agent MCP tools. Call `flushOperation` after abandonment; the global recovery latch clears only after that exact state is durably saved and every other unresolved operation has also been handled. A crash before that flush retains recovery on restart. A failed flush retains the affected mask for another review. A newer cancellation or recovery decision cannot be acknowledged by an older in-flight flush. Recovery review reads at most the configured plan limit synchronously, so the Paper prototype's 4096-block cap also bounds this administrative operation.
|
||||
|
||||
Recovery uses `ContextGuard.checkRecovery(plan)`. The adapter should retain caller authorization, project/world/epoch identity and region/dependency bounds, while excluding restrictions that apply only to block writes, such as paused writing or protected parts. This lets an administrator abandon uncertain history without reopening protected world editing. The interface default delegates to `check(plan)`, so existing adapters retain their complete guard until they explicitly implement this separation.
|
||||
|
||||
A terminal operation reports states observed at runtime; Minecraft's chunk saving is not transactional with this journal, so those observations do not prove disk persistence of the world after a crash. Undo and subsequent plans always reread live state. Fully automatic reconciliation and restart auditing of completed chunk writes remain future work.
|
||||
|
||||
JSON snapshots rewrite the accumulated receipt list after each slice. This prioritizes inspectability and correctness for the initial small-plan prototype; it is not the final storage design or a claim of 100,000-block throughput. Prepare is synchronous and bounded by `maxChanges`; the Paper prototype should cap it conservatively until chunked snapshot preparation exists. The nanosecond budget is a soft limit checked between blocks, not a preemptive deadline for a slow server API call or dependency precheck.
|
||||
|
||||
## Geometry contract
|
||||
|
||||
The compiler accepts `{ "version": 1, "operations": [...] }`. Coordinates are integer world coordinates. Later operations overwrite earlier positions in the result; the compiler itself never reads or writes the world.
|
||||
|
||||
- `box`: inclusive `min`/`max`, `block`, optional `hollow`. Hollow keeps all six boundary faces.
|
||||
- `line`: `from`/`to`, `block`. Both endpoints are included; interpolation makes a deterministic 26-connected voxel line.
|
||||
- `cylinder`: bottom `center`, integer nonnegative `radius`, positive `height`, `block`, optional `hollow`. Hollow retains the side wall and has open top/bottom.
|
||||
- `repeat`: `count` (1–1024), integer `offset`, nested `operations`. Iteration zero uses zero offset.
|
||||
|
||||
The compiler enforces unique block count, scan budget, nesting depth 8, expanded operation count 4096, checked coordinate arithmetic, and strict fields. Overlapping repeated shapes still consume scan budget. Arbitrary scripts, expressions, transforms, palettes, `.schem`, arch generation, and replacement masks are not implemented here yet.
|
||||
|
||||
## Verification
|
||||
|
||||
Run `mvn -f world-core/pom.xml test` from the repository root. Tests cover mutation during journal IO and between slices, read dependencies, partial cancellation, ownership of skipped blocks, changed permissions/policies, idempotency through a real filesystem restart, undo conflicts and later writers, disk failures, corruption, setter exceptions after mutation, nested world callbacks, final verification, geometry limits, and a stalled journal that must not block status or cancellation.
|
||||
|
||||
Memory-world tests prove the core's state machine and journal boundaries. They do not prove Minecraft threading, chunk persistence, physics behavior, or server performance; those require the real Paper integration tests.
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>io.github.minecraftbuilder</groupId><artifactId>minecraft-builder-mcp</artifactId><version>0.1.0-SNAPSHOT</version></parent>
|
||||
<artifactId>world-core</artifactId>
|
||||
<properties><maven.compiler.release>17</maven.compiler.release></properties>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.google.code.gson</groupId><artifactId>gson</artifactId><version>2.11.0</version></dependency>
|
||||
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>5.11.4</version><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,6 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface BlockPolicy {
|
||||
boolean supports(String canonicalState);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
public record BlockPos(int x, int y, int z) implements Comparable<BlockPos> {
|
||||
public BlockPos add(BlockPos offset) {
|
||||
return new BlockPos(Math.addExact(x, offset.x), Math.addExact(y, offset.y), Math.addExact(z, offset.z));
|
||||
}
|
||||
|
||||
@Override public int compareTo(BlockPos other) {
|
||||
int cmp = Integer.compare(y, other.y);
|
||||
if (cmp == 0) cmp = Integer.compare(z, other.z);
|
||||
if (cmp == 0) cmp = Integer.compare(x, other.x);
|
||||
return cmp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record Change(BlockPos pos, String expected, String desired) {
|
||||
public Change { Objects.requireNonNull(pos); Objects.requireNonNull(expected); Objects.requireNonNull(desired); }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
public record Conflict(BlockPos pos, String expected, String current, String desired, String reason) { }
|
||||
@@ -0,0 +1,15 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
/** Throw to stop on revoked permission, changed world epoch, unloaded chunks, or changed region. */
|
||||
@FunctionalInterface
|
||||
public interface ContextGuard {
|
||||
void check(Plan plan);
|
||||
|
||||
/**
|
||||
* Validate context for administrative inspection/abandonment, which never writes world blocks.
|
||||
* Adapters may omit write-only restrictions (paused writes, protected parts), while retaining
|
||||
* project/world/epoch identity, region bounds, and caller authorization. The conservative default
|
||||
* preserves the complete write guard for existing adapters until they explicitly separate it.
|
||||
*/
|
||||
default void checkRecovery(Plan plan) { check(plan); }
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Comparator;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Bounded optimistic editor for one world. The adapter serializes world-thread calls and allows
|
||||
* only one IO task at a time. persistPlan, persistIntent, and flushOperation run on an IO executor.
|
||||
* No world writes occur before a durable intent, nor before the post-IO live recheck.
|
||||
*/
|
||||
public final class EditEngine {
|
||||
private final WorldAccess world;
|
||||
private final BlockPolicy policy;
|
||||
private final ContextGuard guard;
|
||||
private final Journal journal;
|
||||
private final Limits limits;
|
||||
private final Map<String, Plan> plans = new HashMap<>();
|
||||
private final Set<String> durablePlans = new HashSet<>();
|
||||
private final Map<String, Operation> operations = new ConcurrentHashMap<>();
|
||||
private final Set<String> pendingOperationIds = new LinkedHashSet<>();
|
||||
private final Set<String> recoveryOperationIds = new HashSet<>();
|
||||
private final Deque<String> recentOperationIds = new ArrayDeque<>();
|
||||
private final Map<String, String> idempotency = new HashMap<>();
|
||||
private final Map<BlockPos, Writer> lastWriters = new HashMap<>();
|
||||
private long revision;
|
||||
private boolean recoveryRequired;
|
||||
|
||||
private record Writer(String operationId, long revision) { }
|
||||
private static final class Operation {
|
||||
final String id;
|
||||
final Plan plan;
|
||||
final String key;
|
||||
OperationStatus state = OperationStatus.QUEUED;
|
||||
int cursor, skipped, preflightCursor, finalAuditCursor, nextSequence;
|
||||
final List<Receipt> receipts = new ArrayList<>();
|
||||
final List<Conflict> conflicts = new ArrayList<>();
|
||||
final Map<BlockPos, String> written = new HashMap<>();
|
||||
SliceIntent pending;
|
||||
boolean intentDurable;
|
||||
boolean needsFlush = true;
|
||||
long mutationVersion;
|
||||
volatile boolean cancelled;
|
||||
String message = "Awaiting bounded preflight";
|
||||
long abandonedRevision;
|
||||
List<BlockPos> abandonedPositions = List.of();
|
||||
Operation(String id, Plan plan, String key) { this.id = id; this.plan = plan; this.key = key; }
|
||||
OperationSnapshot snapshot() {
|
||||
return new OperationSnapshot(id, plan.id(), key, state, cursor, skipped, preflightCursor,
|
||||
nextSequence, receipts, pending, conflicts, message, cancelled, abandonedRevision, abandonedPositions);
|
||||
}
|
||||
OperationView view() {
|
||||
return new OperationView(id, plan.id(), plan.projectId(), state, plan.changes().size(), cursor,
|
||||
receipts.size(), skipped, preflightCursor, List.copyOf(conflicts), message, needsFlush, cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
public EditEngine(WorldAccess world, BlockPolicy policy, ContextGuard guard, Journal journal, Limits limits)
|
||||
throws IOException {
|
||||
this.world = Objects.requireNonNull(world); this.policy = Objects.requireNonNull(policy);
|
||||
this.guard = Objects.requireNonNull(guard); this.journal = Objects.requireNonNull(journal);
|
||||
this.limits = Objects.requireNonNull(limits);
|
||||
for (Plan plan : journal.loadPlans()) {
|
||||
validatePlanShape(plan);
|
||||
if (plans.put(plan.id(), plan) != null) throw new IOException("Duplicate plan ID");
|
||||
durablePlans.add(plan.id());
|
||||
}
|
||||
// Plan capture time approximates historical creation order; operation IDs break ties stably.
|
||||
List<OperationSnapshot> savedOperations = new ArrayList<>(journal.loadOperations());
|
||||
savedOperations.sort(Comparator.<OperationSnapshot>comparingLong(saved -> {
|
||||
Plan plan = plans.get(saved.planId()); return plan == null ? Long.MIN_VALUE : plan.createdAtMillis();
|
||||
}).thenComparing(OperationSnapshot::id));
|
||||
for (OperationSnapshot saved : savedOperations) restore(saved);
|
||||
}
|
||||
|
||||
private void restore(OperationSnapshot saved) throws IOException {
|
||||
Plan plan = plans.get(saved.planId());
|
||||
if (plan == null) throw new IOException("Operation refers to a missing durable plan");
|
||||
if (saved.cursor() < 0 || saved.cursor() > plan.changes().size() || saved.skipped() < 0
|
||||
|| saved.preflightCursor() < 0 || saved.preflightCursor() > plan.changes().size()
|
||||
|| saved.receipts().size() + saved.skipped() != saved.cursor() || saved.status() == null)
|
||||
throw new IOException("Invalid operation progress");
|
||||
Operation op = new Operation(saved.id(), plan, saved.idempotencyKey());
|
||||
op.state = saved.status(); op.cursor = saved.cursor(); op.skipped = saved.skipped();
|
||||
op.preflightCursor = saved.preflightCursor(); op.nextSequence = saved.nextSequence();
|
||||
op.receipts.addAll(saved.receipts()); op.conflicts.addAll(saved.conflicts());
|
||||
op.pending = saved.pending(); op.cancelled = saved.cancellationRequested(); op.message = saved.message();
|
||||
op.abandonedRevision = saved.abandonedRevision(); op.abandonedPositions = saved.abandonedPositions();
|
||||
if (op.abandonedRevision < 0 || op.abandonedPositions.size() > limits.maxChanges()
|
||||
|| (op.abandonedRevision == 0 && !op.abandonedPositions.isEmpty())
|
||||
|| (op.abandonedRevision > 0 && (op.pending != null
|
||||
|| (op.state != OperationStatus.FAILED && op.state != OperationStatus.RECOVERY_REQUIRED))))
|
||||
throw new IOException("Invalid abandonment record");
|
||||
op.needsFlush = false;
|
||||
for (Receipt receipt : saved.receipts()) {
|
||||
if (!plan.region().contains(receipt.change().pos()) || receipt.revision() < 1)
|
||||
throw new IOException("Invalid write receipt");
|
||||
op.written.put(receipt.change().pos(), receipt.change().desired());
|
||||
Writer previous = lastWriters.get(receipt.change().pos());
|
||||
if (previous == null || previous.revision() < receipt.revision())
|
||||
lastWriters.put(receipt.change().pos(), new Writer(op.id, receipt.revision()));
|
||||
revision = Math.max(revision, receipt.revision());
|
||||
}
|
||||
if (op.abandonedRevision > 0) {
|
||||
Set<BlockPos> possible = new HashSet<>();
|
||||
for (Change change : op.plan.changes()) possible.add(change.pos());
|
||||
for (BlockPos position : op.abandonedPositions) {
|
||||
if (!possible.contains(position)) throw new IOException("Abandoned position is outside the recorded plan");
|
||||
Writer previous = lastWriters.get(position);
|
||||
if (previous == null || previous.revision() < op.abandonedRevision)
|
||||
lastWriters.put(position, new Writer("abandoned:" + op.id, op.abandonedRevision));
|
||||
}
|
||||
revision = Math.max(revision, op.abandonedRevision);
|
||||
}
|
||||
if (!op.state.terminal() || op.pending != null || op.state == OperationStatus.RECOVERY_REQUIRED) {
|
||||
op.state = OperationStatus.RECOVERY_REQUIRED;
|
||||
op.message = "Interrupted operation: inspect before/after/current; automatic replay is disabled";
|
||||
op.needsFlush = true; recoveryRequired = true; recoveryOperationIds.add(op.id);
|
||||
}
|
||||
if (operations.put(op.id, op) != null || idempotency.put(key(plan.projectId(), op.key), op.id) != null)
|
||||
throw new IOException("Duplicate operation or idempotency key");
|
||||
recentOperationIds.addFirst(op.id);
|
||||
updatePending(op);
|
||||
}
|
||||
|
||||
/** Captures a limited live snapshot; perform on the world thread. No disk writes or world mutations. */
|
||||
public synchronized Plan prepare(String projectId, String worldEpoch, Region region,
|
||||
Map<BlockPos, String> desired, Set<BlockPos> dependencyPositions) {
|
||||
requireText(projectId, "projectId"); requireText(worldEpoch, "worldEpoch");
|
||||
Objects.requireNonNull(region); Objects.requireNonNull(desired); Objects.requireNonNull(dependencyPositions);
|
||||
if (desired.size() > limits.maxChanges()) throw new IllegalArgumentException("Plan exceeds block limit");
|
||||
if (dependencyPositions.size() > limits.maxReadDependencies())
|
||||
throw new IllegalArgumentException("Read dependencies exceed limit");
|
||||
long now = System.currentTimeMillis();
|
||||
String id = UUID.randomUUID().toString();
|
||||
Plan context = new Plan(id, projectId, worldEpoch, region, List.of(), List.of(), now,
|
||||
Math.addExact(now, limits.planTtlMillis()), null);
|
||||
guard.check(context);
|
||||
// Validate the whole request before any live reads (and before expensive snapshot work).
|
||||
for (var entry : desired.entrySet()) { requireInside(region, entry.getKey()); requireSupported(entry.getValue()); }
|
||||
for (BlockPos position : dependencyPositions) requireInside(region, position);
|
||||
List<Change> changes = new ArrayList<>();
|
||||
Map<BlockPos, String> captured = new HashMap<>();
|
||||
for (var entry : new TreeMap<>(desired).entrySet()) {
|
||||
String before = read(entry.getKey());
|
||||
requireSupported(before); captured.put(entry.getKey(), before);
|
||||
changes.add(new Change(entry.getKey(), before, entry.getValue()));
|
||||
}
|
||||
List<Plan.Dependency> dependencies = new ArrayList<>();
|
||||
for (BlockPos position : dependencyPositions.stream().sorted().toList()) {
|
||||
String before = captured.containsKey(position) ? captured.get(position) : read(position);
|
||||
requireSupported(before); dependencies.add(new Plan.Dependency(position, before));
|
||||
}
|
||||
Plan plan = new Plan(id, projectId, worldEpoch, region, changes, dependencies, now,
|
||||
context.expiresAtMillis(), null);
|
||||
plans.put(id, plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** IO executor only. A plan cannot start until this succeeds. */
|
||||
public void persistPlan(String planId) throws IOException {
|
||||
Plan plan;
|
||||
synchronized (this) { plan = plan(planId); }
|
||||
journal.savePlan(plan);
|
||||
synchronized (this) { durablePlans.add(planId); }
|
||||
}
|
||||
|
||||
/** Creates a memory operation. Persist it with flushOperation before returning a durable operation ID. */
|
||||
public synchronized OperationView start(String planId, String idempotencyKey) {
|
||||
Plan plan = plan(planId);
|
||||
requireText(idempotencyKey, "idempotencyKey");
|
||||
String existingId = idempotency.get(key(plan.projectId(), idempotencyKey));
|
||||
if (existingId != null) {
|
||||
Operation existing = operation(existingId);
|
||||
if (!existing.plan.id().equals(planId)) throw new IllegalArgumentException("Idempotency key belongs to another plan");
|
||||
return existing.view();
|
||||
}
|
||||
if (recoveryRequired) throw new IllegalStateException("recovery_required: unresolved journal prevents new writes");
|
||||
if (!durablePlans.contains(planId)) throw new IllegalStateException("Plan is not durable; persistPlan first");
|
||||
if (System.currentTimeMillis() > plan.expiresAtMillis()) throw new IllegalStateException("Plan expired; prepare again");
|
||||
guard.check(plan);
|
||||
if (!pendingOperationIds.isEmpty())
|
||||
throw new IllegalStateException("busy: one active or unflushed operation is allowed per world engine");
|
||||
Operation op = new Operation(UUID.randomUUID().toString(), plan, idempotencyKey);
|
||||
if (plan.changes().isEmpty()) { op.state = OperationStatus.APPLIED; op.message = "No changes"; }
|
||||
operations.put(op.id, op); idempotency.put(key(plan.projectId(), idempotencyKey), op.id);
|
||||
recentOperationIds.addFirst(op.id); updatePending(op);
|
||||
return op.view();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded world-thread preflight, then stage a slice. Empty means poll again if status is active.
|
||||
* If status.needsFlush, flush off-thread before polling again. No world writes occur here.
|
||||
*/
|
||||
public synchronized Optional<SliceIntent> stageSlice(String operationId) {
|
||||
Operation op = operation(operationId);
|
||||
if (op.state.terminal()) return Optional.empty();
|
||||
if (op.needsFlush) throw new IllegalStateException("flushOperation must complete before the next slice");
|
||||
if (op.pending != null) throw new IllegalStateException("A slice is already staged");
|
||||
try { return stageInternal(op); }
|
||||
catch (RuntimeException e) {
|
||||
finish(op, OperationStatus.FAILED, "Cannot inspect world: " + e.getMessage());
|
||||
return Optional.empty();
|
||||
} finally { op.mutationVersion++; }
|
||||
}
|
||||
|
||||
private Optional<SliceIntent> stageInternal(Operation op) {
|
||||
if (!checkContext(op) || !checkDependencies(op)) return Optional.empty();
|
||||
long started = System.nanoTime();
|
||||
if (op.preflightCursor < op.plan.changes().size()) {
|
||||
int checked = 0;
|
||||
while (op.preflightCursor < op.plan.changes().size() && checked < limits.maxBlocksPerSlice()) {
|
||||
Change change = op.plan.changes().get(op.preflightCursor);
|
||||
if (!checkChange(op, change, true)) return Optional.empty();
|
||||
op.preflightCursor++; checked++;
|
||||
if (System.nanoTime() - started >= limits.sliceNanos()) break;
|
||||
}
|
||||
op.message = "Preflight " + op.preflightCursor + "/" + op.plan.changes().size();
|
||||
return Optional.empty();
|
||||
}
|
||||
if (op.cursor == op.plan.changes().size()) {
|
||||
int checked = 0;
|
||||
while (op.finalAuditCursor < op.plan.changes().size() && checked < limits.maxBlocksPerSlice()) {
|
||||
Change change = op.plan.changes().get(op.finalAuditCursor);
|
||||
String current = read(change.pos());
|
||||
if (!current.equals(change.desired())) {
|
||||
conflict(op, change, current, "Final verification differs from desired state");
|
||||
return Optional.empty();
|
||||
}
|
||||
op.finalAuditCursor++; checked++;
|
||||
if (System.nanoTime() - started >= limits.sliceNanos()) break;
|
||||
}
|
||||
if (op.finalAuditCursor == op.plan.changes().size())
|
||||
finish(op, OperationStatus.APPLIED, "All requested states verified; snapshot is not an atomic world transaction");
|
||||
return Optional.empty();
|
||||
}
|
||||
List<Change> slice = new ArrayList<>();
|
||||
for (int index = op.cursor; index < op.plan.changes().size() && slice.size() < limits.maxBlocksPerSlice(); index++) {
|
||||
Change change = op.plan.changes().get(index);
|
||||
if (!checkChange(op, change, true)) return Optional.empty();
|
||||
// The journal records the actual state observed now, including no-op desired states.
|
||||
slice.add(new Change(change.pos(), read(change.pos()), change.desired()));
|
||||
if (System.nanoTime() - started >= limits.sliceNanos()) break;
|
||||
}
|
||||
op.pending = new SliceIntent(op.id, op.nextSequence++, slice);
|
||||
op.state = OperationStatus.APPLYING; op.message = "Intent staged; no blocks written";
|
||||
op.intentDurable = false;
|
||||
return Optional.of(op.pending);
|
||||
}
|
||||
|
||||
/** IO executor only. Failure stops the engine; a partially successful fsync cannot be guessed. */
|
||||
public void persistIntent(SliceIntent intent) throws IOException {
|
||||
Operation op;
|
||||
OperationSnapshot snapshot;
|
||||
synchronized (this) {
|
||||
op = requireIntent(intent);
|
||||
if (op.intentDurable) return;
|
||||
snapshot = op.snapshot();
|
||||
}
|
||||
try {
|
||||
journal.saveOperation(snapshot);
|
||||
synchronized (this) {
|
||||
if (op.pending != intent) throw new IllegalStateException("Slice changed during persistence");
|
||||
op.intentDurable = true;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
synchronized (this) { recovery(op, "Cannot persist write intent: " + e.getMessage()); }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** World thread only: revalidate after IO, write a bounded slice, verify actual writes. */
|
||||
public synchronized OperationView commitSlice(SliceIntent intent) {
|
||||
Operation op = requireIntent(intent);
|
||||
if (!op.intentDurable) throw new IllegalStateException("Intent is not durable");
|
||||
if (op.state.terminal()) return op.view();
|
||||
try { return commitInternal(op, intent); }
|
||||
catch (RuntimeException e) {
|
||||
recovery(op, "Slice verification failed; retained intent requires reconciliation: " + e.getMessage());
|
||||
return op.view();
|
||||
} finally { op.mutationVersion++; }
|
||||
}
|
||||
|
||||
private OperationView commitInternal(Operation op, SliceIntent intent) {
|
||||
if (!checkContext(op) || !checkDependencies(op)) { discardIntent(op); return op.view(); }
|
||||
for (Change change : intent.changes()) {
|
||||
// Strict equality to the journaled before value closes the IO race, including no-op entries.
|
||||
String current = read(change.pos());
|
||||
if (!checkUndoWriter(op, change, current)) { discardIntent(op); return op.view(); }
|
||||
if (!current.equals(change.expected()) || !policy.supports(current) || !policy.supports(change.desired())) {
|
||||
conflict(op, change, current, "Changed while intent was persisted"); discardIntent(op); return op.view();
|
||||
}
|
||||
}
|
||||
long started = System.nanoTime();
|
||||
for (Change change : intent.changes()) {
|
||||
if (op.cancelled) { finish(op, OperationStatus.CANCELLED, "Stopped between blocks; completed writes remain"); break; }
|
||||
String current = read(change.pos());
|
||||
if (!checkUndoWriter(op, change, current)) break;
|
||||
if (!current.equals(change.expected())) {
|
||||
conflict(op, change, current, "Changed during slice (possible nested world callback)"); break;
|
||||
}
|
||||
if (current.equals(change.desired())) { op.skipped++; op.cursor++; }
|
||||
else {
|
||||
try {
|
||||
world.setBlock(change.pos(), change.desired());
|
||||
String actual = read(change.pos());
|
||||
if (!actual.equals(change.desired())) {
|
||||
recovery(op, "Write result is uncertain at " + change.pos()); break;
|
||||
}
|
||||
recordWrite(op, change);
|
||||
} catch (RuntimeException e) {
|
||||
// A setter can fail after mutation. Observe and retain a confirmed receipt when possible.
|
||||
try {
|
||||
String actual = read(change.pos());
|
||||
if (actual.equals(change.desired())) recordWrite(op, change);
|
||||
else if (!actual.equals(change.expected())) { recovery(op, "Setter failed with an unexpected world state"); break; }
|
||||
finish(op, OperationStatus.FAILED, "World write failed: " + e.getMessage());
|
||||
} catch (RuntimeException readFailure) { recovery(op, "World write and verification failed"); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (System.nanoTime() - started >= limits.sliceNanos()) break;
|
||||
}
|
||||
if (op.state != OperationStatus.RECOVERY_REQUIRED) discardIntent(op);
|
||||
op.needsFlush = true;
|
||||
if (!op.state.terminal()) op.message = "Slice written; awaiting durable receipt";
|
||||
return op.view();
|
||||
}
|
||||
|
||||
/** IO executor only; the next slice is forbidden until receipts/status have been flushed. */
|
||||
public void flushOperation(String operationId) throws IOException {
|
||||
Operation op;
|
||||
OperationSnapshot snapshot;
|
||||
long snapshotVersion;
|
||||
synchronized (this) {
|
||||
op = operation(operationId);
|
||||
if (op.pending != null && !op.state.terminal())
|
||||
throw new IllegalStateException("Use persistIntent for a staged slice");
|
||||
snapshot = op.snapshot();
|
||||
snapshotVersion = op.mutationVersion;
|
||||
}
|
||||
try {
|
||||
journal.saveOperation(snapshot);
|
||||
synchronized (this) {
|
||||
// Never publish an older flush as the durable acknowledgement of newer state.
|
||||
// Normally only cancellation may interleave; explicit recovery also advances this version.
|
||||
op.needsFlush = op.mutationVersion != snapshotVersion || op.cancelled != snapshot.cancellationRequested();
|
||||
updatePending(op);
|
||||
if (op.abandonedRevision > 0 && op.state == OperationStatus.FAILED && !op.needsFlush) {
|
||||
recoveryOperationIds.remove(op.id);
|
||||
recoveryRequired = !recoveryOperationIds.isEmpty();
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
synchronized (this) { recovery(op, "Cannot persist operation state: " + e.getMessage()); }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancellation does not roll back confirmed blocks. The next stage/commit observes this flag. */
|
||||
public OperationView cancel(String operationId) {
|
||||
Operation op = operation(operationId);
|
||||
boolean wasCancelled = op.cancelled;
|
||||
op.cancelled = true;
|
||||
synchronized (this) { if (!wasCancelled) op.mutationVersion++; return op.view(); }
|
||||
}
|
||||
|
||||
public synchronized OperationView status(String operationId) { return operation(operationId).view(); }
|
||||
/** Internal adapter use only: potentially large; do not send the full receipt list to the model. */
|
||||
public synchronized List<Receipt> receipts(String operationId) { return List.copyOf(operation(operationId).receipts); }
|
||||
public synchronized List<OperationView> operations() {
|
||||
return operations.values().stream().map(Operation::view).sorted(java.util.Comparator.comparing(OperationView::id)).toList();
|
||||
}
|
||||
/** Scheduler index: only active operations or terminal states still requiring a durable flush. */
|
||||
public synchronized List<OperationView> pendingOperations() {
|
||||
return pendingOperationIds.stream().map(id -> operation(id).view()).toList();
|
||||
}
|
||||
/** Bounded context snapshot; most recently created operations first, no receipt copies. */
|
||||
public synchronized List<OperationView> recentOperations(int limit) {
|
||||
if (limit < 0 || limit > 100) throw new IllegalArgumentException("Recent operation limit must be 0..100");
|
||||
List<OperationView> result = new ArrayList<>(Math.min(limit, recentOperationIds.size()));
|
||||
for (String id : recentOperationIds) {
|
||||
if (result.size() == limit) break;
|
||||
result.add(operation(id).view());
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
public int operationCount() { return operations.size(); }
|
||||
|
||||
/**
|
||||
* Record an observed external edit on the server thread, even when its value later returns to the
|
||||
* same state (ABA). This invalidates only known ownership; it does not make journal IO in an event.
|
||||
* The notification is ephemeral and must not be emitted for this engine's own writes.
|
||||
*/
|
||||
public synchronized void recordExternal(BlockPos position) {
|
||||
Objects.requireNonNull(position);
|
||||
// Unknown/unowned world positions need no history allocation.
|
||||
if (!lastWriters.containsKey(position)) return;
|
||||
revision = Math.incrementExact(revision);
|
||||
lastWriters.put(position, new Writer("external", revision));
|
||||
}
|
||||
public synchronized Plan plan(String planId) {
|
||||
Plan result = plans.get(planId);
|
||||
if (result == null) throw new IllegalArgumentException("Unknown plan");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Prepare the inverse of confirmed writes; known later writers or changed values reject the whole undo. */
|
||||
public synchronized Plan prepareUndo(String operationId) {
|
||||
Operation source = operation(operationId);
|
||||
if (!source.state.terminal() || source.state == OperationStatus.RECOVERY_REQUIRED || source.needsFlush
|
||||
|| source.abandonedRevision > 0)
|
||||
throw new IllegalStateException("Undo requires a terminal, durable, reconciled operation");
|
||||
guard.check(source.plan);
|
||||
Map<BlockPos, String> desired = new LinkedHashMap<>();
|
||||
for (Receipt receipt : source.receipts) {
|
||||
Change change = receipt.change();
|
||||
Writer writer = lastWriters.get(change.pos());
|
||||
if (writer == null || !writer.operationId().equals(source.id))
|
||||
throw new IllegalStateException("Undo conflict: known later operation at " + change.pos());
|
||||
String actual = read(change.pos());
|
||||
if (!actual.equals(change.desired())) throw new IllegalStateException("Undo conflict: external change at " + change.pos());
|
||||
desired.put(change.pos(), change.expected());
|
||||
}
|
||||
Plan base = prepare(source.plan.projectId(), source.plan.worldEpoch(), source.plan.region(), desired, Set.of());
|
||||
Plan undo = new Plan(base.id(), base.projectId(), base.worldEpoch(), base.region(), base.changes(),
|
||||
base.dependencies(), base.createdAtMillis(), base.expiresAtMillis(), source.id);
|
||||
plans.put(undo.id(), undo);
|
||||
return undo;
|
||||
}
|
||||
|
||||
/** Read-only bounded inspection of uncertain intent entries; recovery never auto-replays them. */
|
||||
public synchronized List<Conflict> inspectRecovery(String operationId, int offset, int limit) {
|
||||
Operation op = operation(operationId);
|
||||
if (op.state != OperationStatus.RECOVERY_REQUIRED) throw new IllegalStateException("Operation does not require recovery");
|
||||
guard.checkRecovery(op.plan);
|
||||
if (offset < 0 || limit < 1 || limit > limits.maxBlocksPerSlice()) throw new IllegalArgumentException("Invalid recovery page");
|
||||
List<Change> candidates = recoveryChanges(op);
|
||||
List<Conflict> result = new ArrayList<>();
|
||||
for (int i = offset; i < candidates.size() && result.size() < limit; i++) {
|
||||
Change change = candidates.get(i);
|
||||
String actual = read(change.pos());
|
||||
String reason = actual.equals(change.expected()) ? "matches_before" : actual.equals(change.desired()) ? "matches_after" : "foreign_state";
|
||||
result.add(new Conflict(change.pos(), change.expected(), actual, change.desired(), reason));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server thread, explicit administrator use only. Reads at most maxChanges affected positions.
|
||||
* The adapter should expose this outside model-accessible tools. No ownership is inferred.
|
||||
*/
|
||||
public synchronized RecoveryReview reviewRecovery(String operationId) {
|
||||
Operation op = operation(operationId);
|
||||
if (op.state != OperationStatus.RECOVERY_REQUIRED) throw new IllegalStateException("Operation does not require recovery");
|
||||
guard.checkRecovery(op.plan);
|
||||
return recoveryReview(op);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly abandon uncertain history, leaving every world block untouched. The current-state
|
||||
* digest must match a fresh review. Flush off-thread before any new editing can be enabled.
|
||||
*/
|
||||
public synchronized OperationView abandonRecovery(String operationId, String expectedDigest) {
|
||||
Operation op = operation(operationId);
|
||||
if (op.state != OperationStatus.RECOVERY_REQUIRED) throw new IllegalStateException("Operation does not require recovery");
|
||||
if (expectedDigest == null || !expectedDigest.matches("[0-9a-f]{64}"))
|
||||
throw new IllegalArgumentException("A SHA-256 digest from reviewRecovery is required");
|
||||
guard.checkRecovery(op.plan);
|
||||
RecoveryReview current = recoveryReview(op);
|
||||
if (!MessageDigest.isEqual(current.currentDigest().getBytes(StandardCharsets.US_ASCII),
|
||||
expectedDigest.getBytes(StandardCharsets.US_ASCII)))
|
||||
throw new IllegalStateException("Recovery mask changed since review; inspect and review again");
|
||||
List<BlockPos> abandoned = recoveryChanges(op).stream().map(Change::pos).toList();
|
||||
revision = Math.incrementExact(revision);
|
||||
op.abandonedRevision = revision; op.abandonedPositions = abandoned;
|
||||
for (BlockPos position : abandoned)
|
||||
lastWriters.put(position, new Writer("abandoned:" + op.id, revision));
|
||||
discardIntent(op);
|
||||
finish(op, OperationStatus.FAILED, "Administrator abandoned uncertain history; world unchanged; this operation cannot be undone");
|
||||
// Deliberately keep the recovery latch until the exact abandonment state has been persisted.
|
||||
recoveryOperationIds.add(op.id); recoveryRequired = true;
|
||||
return op.view();
|
||||
}
|
||||
|
||||
private RecoveryReview recoveryReview(Operation op) {
|
||||
MessageDigest digest;
|
||||
try { digest = MessageDigest.getInstance("SHA-256"); }
|
||||
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
digestString(digest, "minecraft-builder-mcp-recovery-v1"); digestString(digest, op.id);
|
||||
digestString(digest, op.plan.id()); digestString(digest, op.plan.region().worldId());
|
||||
digestString(digest, op.plan.worldEpoch());
|
||||
int before = 0, after = 0, foreign = 0;
|
||||
List<Change> changes = recoveryChanges(op);
|
||||
for (Change change : changes) {
|
||||
digest.update(ByteBuffer.allocate(12).putInt(change.pos().x()).putInt(change.pos().y()).putInt(change.pos().z()).array());
|
||||
String actual = read(change.pos());
|
||||
digestString(digest, change.expected()); digestString(digest, change.desired()); digestString(digest, actual);
|
||||
if (actual.equals(change.expected())) before++;
|
||||
else if (actual.equals(change.desired())) after++;
|
||||
else foreign++;
|
||||
}
|
||||
return new RecoveryReview(op.id, op.plan.id(), changes.size(), before, after, foreign,
|
||||
HexFormat.of().formatHex(digest.digest()), System.currentTimeMillis());
|
||||
}
|
||||
|
||||
private static void digestString(MessageDigest digest, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
digest.update(ByteBuffer.allocate(4).putInt(bytes.length).array()); digest.update(bytes);
|
||||
}
|
||||
|
||||
private List<Change> recoveryChanges(Operation op) {
|
||||
Map<BlockPos, Change> candidates = new TreeMap<>();
|
||||
for (Receipt receipt : op.receipts) candidates.put(receipt.change().pos(), receipt.change());
|
||||
if (op.pending != null) for (Change change : op.pending.changes()) {
|
||||
// A journaled no-op was never owned and does not need write reconciliation.
|
||||
if (!change.expected().equals(change.desired())) candidates.put(change.pos(), change);
|
||||
}
|
||||
if (!op.abandonedPositions.isEmpty()) {
|
||||
Set<BlockPos> abandoned = new HashSet<>(op.abandonedPositions);
|
||||
for (Change change : op.plan.changes())
|
||||
if (abandoned.contains(change.pos())) candidates.putIfAbsent(change.pos(), change);
|
||||
}
|
||||
return List.copyOf(candidates.values());
|
||||
}
|
||||
|
||||
private void recordWrite(Operation op, Change change) {
|
||||
long next = Math.incrementExact(revision); revision = next;
|
||||
op.receipts.add(new Receipt(change, next)); op.written.put(change.pos(), change.desired());
|
||||
lastWriters.put(change.pos(), new Writer(op.id, next)); op.cursor++;
|
||||
}
|
||||
private boolean checkContext(Operation op) {
|
||||
if (op.cancelled) { finish(op, OperationStatus.CANCELLED, "Cancelled; confirmed writes remain available for undo"); return false; }
|
||||
if (System.currentTimeMillis() > op.plan.expiresAtMillis()) {
|
||||
finish(op, OperationStatus.FAILED, "Plan expired; prepare from fresh state"); return false;
|
||||
}
|
||||
try { guard.check(op.plan); }
|
||||
catch (RuntimeException e) { finish(op, OperationStatus.FAILED, "Context no longer valid: " + e.getMessage()); return false; }
|
||||
return true;
|
||||
}
|
||||
private boolean checkDependencies(Operation op) {
|
||||
for (Plan.Dependency dependency : op.plan.dependencies()) {
|
||||
String expected = op.written.getOrDefault(dependency.pos(), dependency.expected());
|
||||
String actual = read(dependency.pos());
|
||||
if (!expected.equals(actual)) {
|
||||
conflict(op, new Change(dependency.pos(), expected, expected), actual, "Read dependency changed"); return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private boolean checkChange(Operation op, Change change, boolean allowDesired) {
|
||||
String actual = read(change.pos());
|
||||
if (!policy.supports(actual) || !policy.supports(change.desired()) || !policy.supports(change.expected())) {
|
||||
conflict(op, change, actual, "Current or planned block state is unsupported by the current policy"); return false;
|
||||
}
|
||||
if (!checkUndoWriter(op, change, actual)) return false;
|
||||
if (!actual.equals(change.expected()) && !(allowDesired && actual.equals(change.desired()))) {
|
||||
conflict(op, change, actual, "Write set changed since preparation"); return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private boolean checkUndoWriter(Operation op, Change change, String actual) {
|
||||
if (op.plan.undoOf() != null) {
|
||||
Writer writer = lastWriters.get(change.pos());
|
||||
if (writer == null || !writer.operationId().equals(op.plan.undoOf())) {
|
||||
conflict(op, change, actual, "Known later operation prevents undo"); return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private void conflict(Operation op, Change change, String actual, String reason) {
|
||||
if (op.conflicts.size() < limits.maxConflictDetails())
|
||||
op.conflicts.add(new Conflict(change.pos(), change.expected(), actual, change.desired(), reason));
|
||||
finish(op, OperationStatus.CONFLICT, reason);
|
||||
}
|
||||
private void finish(Operation op, OperationStatus state, String message) {
|
||||
op.state = state; op.message = message; op.needsFlush = true;
|
||||
op.mutationVersion++;
|
||||
updatePending(op);
|
||||
}
|
||||
private void recovery(Operation op, String reason) {
|
||||
recoveryRequired = true; recoveryOperationIds.add(op.id); finish(op, OperationStatus.RECOVERY_REQUIRED, reason);
|
||||
}
|
||||
private void discardIntent(Operation op) { op.pending = null; op.intentDurable = false; op.needsFlush = true; }
|
||||
private void updatePending(Operation op) {
|
||||
if (!op.state.terminal() || op.needsFlush) pendingOperationIds.add(op.id);
|
||||
else pendingOperationIds.remove(op.id);
|
||||
}
|
||||
private Operation requireIntent(SliceIntent intent) {
|
||||
Objects.requireNonNull(intent);
|
||||
Operation op = operation(intent.operationId());
|
||||
if (op.pending != intent) throw new IllegalArgumentException("Stale or foreign slice intent");
|
||||
return op;
|
||||
}
|
||||
private Operation operation(String id) {
|
||||
Operation op = operations.get(id);
|
||||
if (op == null) throw new IllegalArgumentException("Unknown operation");
|
||||
return op;
|
||||
}
|
||||
private String read(BlockPos pos) { return Objects.requireNonNull(world.getBlock(pos), "World returned null state"); }
|
||||
private void requireSupported(String state) {
|
||||
if (state == null || !policy.supports(state)) throw new IllegalArgumentException("Unsupported block state: " + state);
|
||||
}
|
||||
private static void requireInside(Region region, BlockPos pos) {
|
||||
if (pos == null || !region.contains(pos)) throw new IllegalArgumentException("Position outside authorized region: " + pos);
|
||||
}
|
||||
private static void requireText(String value, String name) {
|
||||
if (value == null || value.isBlank() || value.length() > 256 || value.chars().anyMatch(c -> c < 32))
|
||||
throw new IllegalArgumentException("Invalid " + name);
|
||||
}
|
||||
private static String key(String projectId, String idempotencyKey) { return projectId + "\u0000" + idempotencyKey; }
|
||||
private void validatePlanShape(Plan plan) throws IOException {
|
||||
if (plan.changes().size() > limits.maxChanges() || plan.dependencies().size() > limits.maxReadDependencies())
|
||||
throw new IOException("Stored plan exceeds configured limits");
|
||||
Set<BlockPos> positions = new HashSet<>();
|
||||
for (Change change : plan.changes()) {
|
||||
if (!plan.region().contains(change.pos()) || !positions.add(change.pos()))
|
||||
throw new IOException("Stored plan contains invalid/duplicate positions");
|
||||
}
|
||||
for (Plan.Dependency dependency : plan.dependencies())
|
||||
if (!plan.region().contains(dependency.pos())) throw new IOException("Stored dependency is outside region");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/** save must return only after durable persistence; failures must throw, never be swallowed. */
|
||||
public interface Journal {
|
||||
void savePlan(Plan plan) throws IOException;
|
||||
void saveOperation(OperationSnapshot operation) throws IOException;
|
||||
List<Plan> loadPlans() throws IOException;
|
||||
List<OperationSnapshot> loadOperations() throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonParseException;
|
||||
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;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Atomic, checksummed JSON snapshots. Requires a filesystem supporting atomic rename and directory fsync. */
|
||||
public final class JsonJournal implements Journal {
|
||||
private static final long MAX_RECORD_BYTES = 128L * 1024 * 1024;
|
||||
private final Gson gson = new Gson();
|
||||
private final Path plans;
|
||||
private final Path operations;
|
||||
private record Envelope(int format, String kind, String id, String payload, String sha256) { }
|
||||
|
||||
public JsonJournal(Path root) throws IOException {
|
||||
Path absolute = root.toAbsolutePath();
|
||||
Files.createDirectories(absolute);
|
||||
plans = absolute.resolve("plans"); operations = absolute.resolve("operations");
|
||||
Files.createDirectories(plans); Files.createDirectories(operations);
|
||||
syncDirectory(absolute);
|
||||
if (absolute.getParent() != null) syncDirectory(absolute.getParent());
|
||||
}
|
||||
@Override public synchronized void savePlan(Plan plan) throws IOException { save(plans, "plan", plan.id(), plan); }
|
||||
@Override public synchronized void saveOperation(OperationSnapshot operation) throws IOException {
|
||||
save(operations, "operation", operation.id(), operation);
|
||||
}
|
||||
@Override public synchronized List<Plan> loadPlans() throws IOException { return load(plans, "plan", Plan.class); }
|
||||
@Override public synchronized List<OperationSnapshot> loadOperations() throws IOException {
|
||||
return load(operations, "operation", OperationSnapshot.class);
|
||||
}
|
||||
private void save(Path directory, String kind, String id, Object value) throws IOException {
|
||||
requireId(id);
|
||||
String payload = gson.toJson(value);
|
||||
byte[] bytes = gson.toJson(new Envelope(1, kind, id, payload, checksum(payload))).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_RECORD_BYTES) throw new IOException("Journal record exceeds size limit");
|
||||
Path temporary = Files.createTempFile(directory, ".pending-", ".tmp");
|
||||
try {
|
||||
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) channel.write(buffer);
|
||||
channel.force(true);
|
||||
}
|
||||
Files.move(temporary, directory.resolve(id + ".json"), StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
syncDirectory(directory);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
}
|
||||
private <T> List<T> load(Path directory, String kind, Class<T> type) throws IOException {
|
||||
List<T> result = new ArrayList<>();
|
||||
try (var files = Files.list(directory)) {
|
||||
for (Path file : files.filter(p -> p.getFileName().toString().endsWith(".json")).sorted().toList()) {
|
||||
if (!Files.isRegularFile(file) || Files.size(file) > MAX_RECORD_BYTES)
|
||||
throw new IOException("Invalid journal record: " + file.getFileName());
|
||||
try {
|
||||
Envelope envelope = gson.fromJson(Files.readString(file), Envelope.class);
|
||||
if (envelope == null || envelope.format != 1 || !kind.equals(envelope.kind)
|
||||
|| !file.getFileName().toString().equals(envelope.id + ".json")
|
||||
|| envelope.payload == null || !checksum(envelope.payload).equals(envelope.sha256))
|
||||
throw new IOException("Corrupt or unsupported journal: " + file.getFileName());
|
||||
requireId(envelope.id);
|
||||
T value = gson.fromJson(envelope.payload, type);
|
||||
String recordId = value instanceof Plan p ? p.id() : ((OperationSnapshot) value).id();
|
||||
if (!envelope.id.equals(recordId)) throw new IOException("Mismatched journal identity");
|
||||
result.add(value);
|
||||
} catch (JsonParseException | IllegalArgumentException | NullPointerException e) {
|
||||
throw new IOException("Cannot parse journal: " + file.getFileName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
private static void requireId(String id) throws IOException {
|
||||
try { if (!UUID.fromString(id).toString().equals(id)) throw new IllegalArgumentException(); }
|
||||
catch (IllegalArgumentException | NullPointerException e) { throw new IOException("Invalid journal ID", e); }
|
||||
}
|
||||
private static String checksum(String payload) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
private static void syncDirectory(Path directory) throws IOException {
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
public record Limits(int maxChanges, int maxReadDependencies, int maxBlocksPerSlice, long sliceNanos,
|
||||
long planTtlMillis, int maxConflictDetails) {
|
||||
public Limits {
|
||||
if (maxChanges < 1 || maxReadDependencies < 0 || maxBlocksPerSlice < 1 || sliceNanos < 1
|
||||
|| planTtlMillis < 1 || maxConflictDetails < 1) throw new IllegalArgumentException("Invalid limits");
|
||||
}
|
||||
public static Limits defaults() { return new Limits(100_000, 512, 512, 5_000_000, 600_000, 32); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Journal wire format; clients should use the bounded OperationView. */
|
||||
public record OperationSnapshot(String id, String planId, String idempotencyKey, OperationStatus status,
|
||||
int cursor, int skipped, int preflightCursor, int nextSequence,
|
||||
List<Receipt> receipts, SliceIntent pending, List<Conflict> conflicts,
|
||||
String message, boolean cancellationRequested, long abandonedRevision,
|
||||
List<BlockPos> abandonedPositions) {
|
||||
public OperationSnapshot {
|
||||
receipts = List.copyOf(receipts); conflicts = List.copyOf(conflicts);
|
||||
// Older journal version 1 records have no abandonment metadata.
|
||||
abandonedPositions = abandonedPositions == null ? List.of() : List.copyOf(abandonedPositions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
public enum OperationStatus {
|
||||
QUEUED, APPLYING, APPLIED, CONFLICT, CANCELLED, FAILED, RECOVERY_REQUIRED;
|
||||
public boolean terminal() { return this != QUEUED && this != APPLYING; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record OperationView(String id, String planId, String projectId, OperationStatus status,
|
||||
int totalChanges, int processed, int written, int skipped,
|
||||
int preflightChecked, List<Conflict> conflicts, String message,
|
||||
boolean needsFlush, boolean cancellationRequested) { }
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** An immutable snapshot. No-change entries remain read dependencies in the plan. */
|
||||
public record Plan(String id, String projectId, String worldEpoch, Region region, List<Change> changes,
|
||||
List<Dependency> dependencies, long createdAtMillis, long expiresAtMillis, String undoOf) {
|
||||
public Plan {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(projectId); Objects.requireNonNull(worldEpoch);
|
||||
Objects.requireNonNull(region); changes = List.copyOf(changes); dependencies = List.copyOf(dependencies);
|
||||
}
|
||||
public record Dependency(BlockPos pos, String expected) {
|
||||
public Dependency { Objects.requireNonNull(pos); Objects.requireNonNull(expected); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
/** Only records a block actually written and observed at its desired state. */
|
||||
public record Receipt(Change change, long revision) { }
|
||||
@@ -0,0 +1,161 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Small deterministic geometry language. Coordinates are world coordinates; later operations win. */
|
||||
public final class RecipeCompiler {
|
||||
private static final int MAX_DEPTH = 8;
|
||||
private static final int MAX_EXPANDED_OPERATIONS = 4096;
|
||||
private RecipeCompiler() { }
|
||||
|
||||
public static Map<BlockPos, String> compile(JsonObject recipe, int maxBlocks) {
|
||||
if (maxBlocks < 1 || maxBlocks > 2_000_000) throw new IllegalArgumentException("Invalid recipe block limit");
|
||||
fields(recipe, Set.of("version", "operations"));
|
||||
if (integer(recipe, "version") != 1) throw new IllegalArgumentException("Unsupported recipe version");
|
||||
Builder builder = new Builder(maxBlocks);
|
||||
builder.operations(array(recipe, "operations"), new BlockPos(0, 0, 0), 0);
|
||||
return java.util.Collections.unmodifiableMap(new LinkedHashMap<>(builder.blocks));
|
||||
}
|
||||
|
||||
private static final class Builder {
|
||||
final int maxBlocks;
|
||||
final long maxVisits;
|
||||
long visits;
|
||||
int operationCount;
|
||||
final Map<BlockPos, String> blocks = new LinkedHashMap<>();
|
||||
Builder(int maxBlocks) { this.maxBlocks = maxBlocks; maxVisits = Math.min(2_000_000L, (long) maxBlocks * 16); }
|
||||
void operations(JsonArray operations, BlockPos offset, int depth) {
|
||||
if (depth > MAX_DEPTH) throw new IllegalArgumentException("Recipe nesting exceeds limit");
|
||||
if (operations.size() > MAX_EXPANDED_OPERATIONS) throw new IllegalArgumentException("Too many operations");
|
||||
for (JsonElement element : operations) {
|
||||
if (++operationCount > MAX_EXPANDED_OPERATIONS) throw new IllegalArgumentException("Expanded operation count exceeds limit");
|
||||
if (!element.isJsonObject()) throw new IllegalArgumentException("Operation must be an object");
|
||||
JsonObject operation = element.getAsJsonObject();
|
||||
String type = string(operation, "type");
|
||||
switch (type) {
|
||||
case "box" -> box(operation, offset);
|
||||
case "line" -> line(operation, offset);
|
||||
case "cylinder" -> cylinder(operation, offset);
|
||||
case "repeat" -> repeat(operation, offset, depth);
|
||||
default -> throw new IllegalArgumentException("Unsupported operation: " + type);
|
||||
}
|
||||
}
|
||||
}
|
||||
void box(JsonObject operation, BlockPos offset) {
|
||||
fields(operation, Set.of("type", "min", "max", "block", "hollow"));
|
||||
BlockPos min = position(operation, "min").add(offset);
|
||||
BlockPos max = position(operation, "max").add(offset);
|
||||
Region region = new Region("recipe", min, max);
|
||||
charge(region.volume());
|
||||
String block = block(operation); boolean hollow = bool(operation, "hollow", false);
|
||||
for (long y = min.y(); y <= max.y(); y++) for (long z = min.z(); z <= max.z(); z++) for (long x = min.x(); x <= max.x(); x++) {
|
||||
if (!hollow || x == min.x() || x == max.x() || y == min.y() || y == max.y() || z == min.z() || z == max.z())
|
||||
put(new BlockPos((int) x, (int) y, (int) z), block);
|
||||
}
|
||||
}
|
||||
void line(JsonObject operation, BlockPos offset) {
|
||||
fields(operation, Set.of("type", "from", "to", "block"));
|
||||
BlockPos from = position(operation, "from").add(offset), to = position(operation, "to").add(offset);
|
||||
long dx = (long) to.x() - from.x(), dy = (long) to.y() - from.y(), dz = (long) to.z() - from.z();
|
||||
long steps = Math.max(Math.max(Math.abs(dx), Math.abs(dy)), Math.abs(dz));
|
||||
charge(steps + 1); String block = block(operation);
|
||||
if (steps == 0) { put(from, block); return; }
|
||||
for (long step = 0; step <= steps; step++) {
|
||||
put(new BlockPos(interpolate(from.x(), dx, step, steps), interpolate(from.y(), dy, step, steps),
|
||||
interpolate(from.z(), dz, step, steps)), block);
|
||||
}
|
||||
}
|
||||
void cylinder(JsonObject operation, BlockPos offset) {
|
||||
fields(operation, Set.of("type", "center", "radius", "height", "block", "hollow"));
|
||||
BlockPos center = position(operation, "center").add(offset);
|
||||
int radius = integer(operation, "radius"), height = integer(operation, "height");
|
||||
if (radius < 0 || height < 1) throw new IllegalArgumentException("Cylinder radius must be nonnegative and height positive");
|
||||
long diameter = Math.addExact(Math.multiplyExact((long) radius, 2), 1);
|
||||
charge(Math.multiplyExact(Math.multiplyExact(diameter, diameter), height));
|
||||
Math.subtractExact(center.x(), radius); Math.addExact(center.x(), radius);
|
||||
Math.subtractExact(center.z(), radius); Math.addExact(center.z(), radius);
|
||||
Math.addExact(center.y(), height - 1);
|
||||
long outer = (long) radius * radius, inner = (long) (radius - 1) * (radius - 1);
|
||||
String block = block(operation); boolean hollow = bool(operation, "hollow", false);
|
||||
for (int y = 0; y < height; y++) for (int z = -radius; z <= radius; z++) for (int x = -radius; x <= radius; x++) {
|
||||
long distance = (long) x * x + (long) z * z;
|
||||
if (distance <= outer && (!hollow || radius == 0 || distance > inner))
|
||||
put(center.add(new BlockPos(x, y, z)), block);
|
||||
}
|
||||
}
|
||||
void repeat(JsonObject operation, BlockPos offset, int depth) {
|
||||
fields(operation, Set.of("type", "count", "offset", "operations"));
|
||||
int count = integer(operation, "count");
|
||||
if (count < 1 || count > 1024) throw new IllegalArgumentException("Repeat count must be 1..1024");
|
||||
BlockPos step = position(operation, "offset"); JsonArray children = array(operation, "operations");
|
||||
for (int i = 0; i < count; i++) {
|
||||
BlockPos displacement = new BlockPos(Math.multiplyExact(step.x(), i), Math.multiplyExact(step.y(), i),
|
||||
Math.multiplyExact(step.z(), i));
|
||||
operations(children, offset.add(displacement), depth + 1);
|
||||
}
|
||||
}
|
||||
void charge(long amount) {
|
||||
visits = Math.addExact(visits, amount);
|
||||
if (visits > maxVisits) throw new IllegalArgumentException("Recipe scan budget exceeded");
|
||||
}
|
||||
void put(BlockPos position, String block) {
|
||||
if (!blocks.containsKey(position) && blocks.size() >= maxBlocks) throw new IllegalArgumentException("Recipe block limit exceeded");
|
||||
blocks.put(position, block);
|
||||
}
|
||||
}
|
||||
|
||||
private static int interpolate(int start, long delta, long step, long steps) {
|
||||
// Integer arithmetic gives deterministic nearest-voxel endpoints without floating-point drift.
|
||||
long numerator = Math.multiplyExact(delta, step);
|
||||
long rounded = Math.floorDiv(Math.addExact(Math.multiplyExact(numerator, 2), steps), Math.multiplyExact(steps, 2));
|
||||
return Math.toIntExact(Math.addExact(start, rounded));
|
||||
}
|
||||
private static String block(JsonObject object) {
|
||||
String block = string(object, "block");
|
||||
if (block.length() > 512 || !block.matches("minecraft:[a-z0-9_]+(?:\\[[a-z0-9_=,]+\\])?"))
|
||||
throw new IllegalArgumentException("Expected a Minecraft block state string");
|
||||
return block;
|
||||
}
|
||||
private static BlockPos position(JsonObject parent, String name) {
|
||||
JsonElement value = required(parent, name);
|
||||
if (!value.isJsonObject()) throw new IllegalArgumentException(name + " must be a position object");
|
||||
JsonObject object = value.getAsJsonObject(); fields(object, Set.of("x", "y", "z"));
|
||||
return new BlockPos(integer(object, "x"), integer(object, "y"), integer(object, "z"));
|
||||
}
|
||||
private static int integer(JsonObject object, String name) {
|
||||
JsonElement value = required(object, name);
|
||||
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber() || !value.getAsString().matches("-?(0|[1-9][0-9]*)"))
|
||||
throw new IllegalArgumentException(name + " must be an integer");
|
||||
try { return Integer.parseInt(value.getAsString()); }
|
||||
catch (NumberFormatException e) { throw new IllegalArgumentException(name + " exceeds 32-bit bounds", e); }
|
||||
}
|
||||
private static String string(JsonObject object, String name) {
|
||||
JsonElement value = required(object, name);
|
||||
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw new IllegalArgumentException(name + " must be a string");
|
||||
return value.getAsString();
|
||||
}
|
||||
private static boolean bool(JsonObject object, String name, boolean fallback) {
|
||||
if (!object.has(name)) return fallback;
|
||||
JsonElement value = object.get(name);
|
||||
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) throw new IllegalArgumentException(name + " must be a boolean");
|
||||
return value.getAsBoolean();
|
||||
}
|
||||
private static JsonArray array(JsonObject object, String name) {
|
||||
JsonElement value = required(object, name);
|
||||
if (!value.isJsonArray()) throw new IllegalArgumentException(name + " must be an array");
|
||||
return value.getAsJsonArray();
|
||||
}
|
||||
private static JsonElement required(JsonObject object, String name) {
|
||||
if (object == null || !object.has(name) || object.get(name).isJsonNull()) throw new IllegalArgumentException("Missing field: " + name);
|
||||
return object.get(name);
|
||||
}
|
||||
private static void fields(JsonObject object, Set<String> accepted) {
|
||||
if (object == null) throw new IllegalArgumentException("Expected an object");
|
||||
for (String key : object.keySet()) if (!accepted.contains(key)) throw new IllegalArgumentException("Unknown field: " + key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
/** Bounded summary for an explicit administrator decision; matching content never proves authorship. */
|
||||
public record RecoveryReview(String operationId, String planId, int positions, int matchesBefore,
|
||||
int matchesAfter, int foreignStates, String currentDigest, long sampledAtMillis) { }
|
||||
@@ -0,0 +1,19 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record Region(String worldId, BlockPos min, BlockPos max) {
|
||||
public Region {
|
||||
Objects.requireNonNull(worldId); Objects.requireNonNull(min); Objects.requireNonNull(max);
|
||||
if (worldId.isBlank() || min.x() > max.x() || min.y() > max.y() || min.z() > max.z())
|
||||
throw new IllegalArgumentException("Invalid inclusive region bounds");
|
||||
}
|
||||
public boolean contains(BlockPos pos) {
|
||||
return pos.x() >= min.x() && pos.x() <= max.x() && pos.y() >= min.y() && pos.y() <= max.y()
|
||||
&& pos.z() >= min.z() && pos.z() <= max.z();
|
||||
}
|
||||
public long volume() {
|
||||
return Math.multiplyExact(Math.multiplyExact((long) max.x() - min.x() + 1,
|
||||
(long) max.y() - min.y() + 1), (long) max.z() - min.z() + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Pass this exact staged object through persistIntent then commitSlice; it is not accepted from clients. */
|
||||
public record SliceIntent(String operationId, int sequence, List<Change> changes) {
|
||||
public SliceIntent { changes = List.copyOf(changes); }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
/** The adapter must call all engine methods that touch this interface on its world thread. */
|
||||
public interface WorldAccess {
|
||||
String getBlock(BlockPos position);
|
||||
/** Apply canonical state without physics; unsupported side effects must be excluded by BlockPolicy. */
|
||||
void setBlock(BlockPos position, String canonicalState);
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class EditEngineTest {
|
||||
static final String AIR = "minecraft:air", STONE = "minecraft:stone", GOLD = "minecraft:gold_block";
|
||||
static final BlockPos A = new BlockPos(0, 0, 0), B = new BlockPos(1, 0, 0), C = new BlockPos(2, 0, 0);
|
||||
static final Region REGION = new Region("world", new BlockPos(-20, -20, -20), new BlockPos(20, 20, 20));
|
||||
static final Limits LIMITS = new Limits(4096, 16, 1, 1_000_000_000, 600_000, 4);
|
||||
@TempDir Path temporary;
|
||||
|
||||
static class MemoryWorld implements WorldAccess {
|
||||
final Map<BlockPos, String> blocks = new HashMap<>(); int writes;
|
||||
Runnable afterWrite;
|
||||
@Override public String getBlock(BlockPos position) { return blocks.getOrDefault(position, AIR); }
|
||||
@Override public void setBlock(BlockPos position, String state) {
|
||||
blocks.put(position, state); writes++;
|
||||
if (afterWrite != null) afterWrite.run();
|
||||
}
|
||||
}
|
||||
static class MemoryJournal implements Journal {
|
||||
final Map<String, Plan> plans = new HashMap<>();
|
||||
final Map<String, OperationSnapshot> operations = new HashMap<>();
|
||||
boolean fail;
|
||||
@Override public void savePlan(Plan plan) throws IOException { check(); plans.put(plan.id(), plan); }
|
||||
@Override public void saveOperation(OperationSnapshot op) throws IOException { check(); operations.put(op.id(), op); }
|
||||
@Override public List<Plan> loadPlans() { return List.copyOf(plans.values()); }
|
||||
@Override public List<OperationSnapshot> loadOperations() { return List.copyOf(operations.values()); }
|
||||
void check() throws IOException { if (fail) throw new IOException("Injected disk failure"); }
|
||||
}
|
||||
static EditEngine engine(MemoryWorld world, Journal journal) throws IOException {
|
||||
return new EditEngine(world, state -> Set.of(AIR, STONE, GOLD).contains(state), plan -> {}, journal, LIMITS);
|
||||
}
|
||||
static Plan prepare(EditEngine engine, Map<BlockPos, String> desired) throws IOException {
|
||||
Plan plan = engine.prepare("project", "epoch", REGION, desired, Set.of());
|
||||
engine.persistPlan(plan.id()); return plan;
|
||||
}
|
||||
static String start(EditEngine engine, Plan plan) throws IOException {
|
||||
String id = engine.start(plan.id(), "request-" + plan.id()).id(); engine.flushOperation(id); return id;
|
||||
}
|
||||
static SliceIntent nextIntent(EditEngine engine, String id) throws IOException {
|
||||
for (int i = 0; i < 100; i++) {
|
||||
var intent = engine.stageSlice(id);
|
||||
if (intent.isPresent()) return intent.get();
|
||||
OperationView view = engine.status(id);
|
||||
if (view.needsFlush()) engine.flushOperation(id);
|
||||
if (view.status().terminal()) throw new AssertionError("Terminal before intent: " + view);
|
||||
}
|
||||
throw new AssertionError("No intent");
|
||||
}
|
||||
static void slice(EditEngine engine, String id) throws IOException {
|
||||
SliceIntent intent = nextIntent(engine, id); engine.persistIntent(intent); engine.commitSlice(intent); engine.flushOperation(id);
|
||||
}
|
||||
static OperationView finish(EditEngine engine, String id) throws IOException {
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
OperationView view = engine.status(id);
|
||||
if (view.needsFlush()) engine.flushOperation(id);
|
||||
if (view.status().terminal()) return engine.status(id);
|
||||
var intent = engine.stageSlice(id);
|
||||
if (intent.isPresent()) { engine.persistIntent(intent.get()); engine.commitSlice(intent.get()); }
|
||||
}
|
||||
throw new AssertionError("Operation failed to terminate");
|
||||
}
|
||||
|
||||
@Test void manualChangeAnywhereBeforeApplyStopsEntirePlan() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
Plan plan = prepare(engine, Map.of(A, STONE, B, STONE, C, STONE));
|
||||
world.blocks.put(C, GOLD); String id = start(engine, plan);
|
||||
assertEquals(OperationStatus.CONFLICT, finish(engine, id).status());
|
||||
assertEquals(0, world.writes); assertEquals(GOLD, world.getBlock(C));
|
||||
}
|
||||
@Test void ioRaceStopsBeforeAnySliceWrites() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE)));
|
||||
SliceIntent intent = nextIntent(engine, id); engine.persistIntent(intent);
|
||||
world.blocks.put(A, GOLD); OperationView result = engine.commitSlice(intent);
|
||||
assertEquals(OperationStatus.CONFLICT, result.status()); assertEquals(0, world.writes);
|
||||
}
|
||||
@Test void noWriteBeforeDurableIntentAndNoNextSliceBeforeDurableReceipt() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE)));
|
||||
SliceIntent intent = nextIntent(engine, id);
|
||||
assertThrows(IllegalStateException.class, () -> engine.commitSlice(intent)); assertEquals(0, world.writes);
|
||||
engine.persistIntent(intent); engine.commitSlice(intent);
|
||||
assertThrows(IllegalStateException.class, () -> engine.stageSlice(id));
|
||||
assertEquals(1, world.writes);
|
||||
}
|
||||
@Test void editBetweenSlicesKeepsConfirmedPartialResult() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE, C, STONE)));
|
||||
slice(engine, id); world.blocks.put(B, GOLD);
|
||||
OperationView result = finish(engine, id);
|
||||
assertEquals(OperationStatus.CONFLICT, result.status()); assertEquals(1, result.written());
|
||||
assertEquals(STONE, world.getBlock(A)); assertEquals(GOLD, world.getBlock(B)); assertEquals(AIR, world.getBlock(C));
|
||||
}
|
||||
@Test void externalChangeToDesiredIsSkippedAndNotOwnedByUndo() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
Plan plan = prepare(engine, Map.of(A, STONE, B, STONE)); world.blocks.put(A, STONE);
|
||||
String id = start(engine, plan); OperationView result = finish(engine, id);
|
||||
assertEquals(OperationStatus.APPLIED, result.status()); assertEquals(1, result.written()); assertEquals(1, result.skipped());
|
||||
Plan undo = engine.prepareUndo(id); engine.persistPlan(undo.id()); finish(engine, start(engine, undo));
|
||||
assertEquals(STONE, world.getBlock(A)); assertEquals(AIR, world.getBlock(B));
|
||||
}
|
||||
@Test void readDependencyChangeBetweenSlicesStopsFutureWrites() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); world.blocks.put(C, STONE);
|
||||
EditEngine engine = engine(world, new MemoryJournal());
|
||||
Plan plan = engine.prepare("project", "epoch", REGION, Map.of(A, STONE, B, STONE), Set.of(C)); engine.persistPlan(plan.id());
|
||||
String id = start(engine, plan); slice(engine, id); world.blocks.put(C, AIR);
|
||||
OperationView result = finish(engine, id);
|
||||
assertEquals(OperationStatus.CONFLICT, result.status()); assertEquals(1, result.written());
|
||||
assertEquals(AIR, world.getBlock(B)); assertEquals(C, result.conflicts().get(0).pos());
|
||||
}
|
||||
@Test void dependencyWrittenByThisOperationUsesConfirmedDesiredValue() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
Plan plan = engine.prepare("project", "epoch", REGION, Map.of(A, STONE, B, STONE), Set.of(A)); engine.persistPlan(plan.id());
|
||||
assertEquals(OperationStatus.APPLIED, finish(engine, start(engine, plan)).status());
|
||||
}
|
||||
@Test void cancellationAfterOneSliceCanBeUndoneWithoutTouchingRemainingBlocks() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE)));
|
||||
slice(engine, id); engine.cancel(id);
|
||||
assertEquals(OperationStatus.CANCELLED, finish(engine, id).status());
|
||||
Plan undo = engine.prepareUndo(id); engine.persistPlan(undo.id()); finish(engine, start(engine, undo));
|
||||
assertEquals(AIR, world.getBlock(A)); assertEquals(AIR, world.getBlock(B)); assertEquals(2, world.writes);
|
||||
}
|
||||
@Test void cancellationWhileIntentIsPersistedWritesNothing() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE)));
|
||||
SliceIntent intent = nextIntent(engine, id); engine.persistIntent(intent); engine.cancel(id);
|
||||
assertEquals(OperationStatus.CANCELLED, engine.commitSlice(intent).status()); assertEquals(0, world.writes);
|
||||
}
|
||||
@Test void repeatedApplyUsesSameOperationAcrossRestartAndRejectsDifferentPlan() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); JsonJournal journal = new JsonJournal(temporary);
|
||||
EditEngine engine = engine(world, journal); Plan plan = prepare(engine, Map.of(A, STONE));
|
||||
String id = start(engine, plan); finish(engine, id);
|
||||
assertEquals(id, engine.start(plan.id(), "request-" + plan.id()).id()); assertEquals(1, world.writes);
|
||||
EditEngine restarted = engine(world, new JsonJournal(temporary));
|
||||
assertEquals(id, restarted.start(plan.id(), "request-" + plan.id()).id()); assertEquals(1, world.writes);
|
||||
Plan other = prepare(restarted, Map.of(B, STONE));
|
||||
assertThrows(IllegalArgumentException.class, () -> restarted.start(other.id(), "request-" + plan.id()));
|
||||
}
|
||||
@Test void undoRejectsExternalEditBeforePreparationOrAfterPreparation() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE))); finish(engine, id);
|
||||
world.blocks.put(A, GOLD); assertThrows(IllegalStateException.class, () -> engine.prepareUndo(id));
|
||||
world.blocks.put(A, STONE); Plan undo = engine.prepareUndo(id); engine.persistPlan(undo.id());
|
||||
world.blocks.put(A, GOLD);
|
||||
assertEquals(OperationStatus.CONFLICT, finish(engine, start(engine, undo)).status()); assertEquals(GOLD, world.getBlock(A));
|
||||
}
|
||||
@Test void undoRejectsKnownLaterWriterEvenWhenCurrentValueMatchesOriginalAfter() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
String first = start(engine, prepare(engine, Map.of(A, STONE))); finish(engine, first);
|
||||
finish(engine, start(engine, prepare(engine, Map.of(A, GOLD))));
|
||||
finish(engine, start(engine, prepare(engine, Map.of(A, STONE))));
|
||||
assertThrows(IllegalStateException.class, () -> engine.prepareUndo(first));
|
||||
}
|
||||
@Test void unsupportedSourceOrDestinationAndOutOfBoundsAreRejectedBeforeWriting() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
assertThrows(IllegalArgumentException.class, () -> prepare(engine, Map.of(A, "minecraft:chest")));
|
||||
world.blocks.put(A, "minecraft:chest");
|
||||
assertThrows(IllegalArgumentException.class, () -> prepare(engine, Map.of(A, AIR)));
|
||||
assertThrows(IllegalArgumentException.class, () -> prepare(engine, Map.of(new BlockPos(21, 0, 0), STONE)));
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.prepare("project", "epoch", REGION, Map.of(B, STONE), Set.of(new BlockPos(0, 21, 0))));
|
||||
assertEquals(0, world.writes);
|
||||
}
|
||||
@Test void permissionRevokedDuringIntentIoStopsSlice() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); AtomicBoolean allowed = new AtomicBoolean(true);
|
||||
EditEngine engine = new EditEngine(world, state -> true, plan -> {
|
||||
if (!allowed.get()) throw new IllegalStateException("access revoked");
|
||||
}, new MemoryJournal(), LIMITS);
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE)));
|
||||
SliceIntent intent = nextIntent(engine, id); engine.persistIntent(intent); allowed.set(false);
|
||||
assertEquals(OperationStatus.FAILED, engine.commitSlice(intent).status()); assertEquals(0, world.writes);
|
||||
}
|
||||
@Test void failedIntentPersistenceNeverWritesAndBlocksFurtherOperations() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine engine = engine(world, journal);
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE)));
|
||||
SliceIntent intent = nextIntent(engine, id); journal.fail = true;
|
||||
assertThrows(IOException.class, () -> engine.persistIntent(intent));
|
||||
assertEquals(OperationStatus.RECOVERY_REQUIRED, engine.status(id).status()); assertEquals(0, world.writes);
|
||||
journal.fail = false; Plan other = prepare(engine, Map.of(B, STONE));
|
||||
assertThrows(IllegalStateException.class, () -> engine.start(other.id(), "other"));
|
||||
}
|
||||
@Test void failedReceiptPersistenceRestartsAsUncertainWithoutReplay() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine engine = engine(world, journal);
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE)));
|
||||
SliceIntent intent = nextIntent(engine, id); engine.persistIntent(intent); engine.commitSlice(intent);
|
||||
journal.fail = true; assertThrows(IOException.class, () -> engine.flushOperation(id)); journal.fail = false;
|
||||
EditEngine restarted = engine(world, journal);
|
||||
assertEquals(OperationStatus.RECOVERY_REQUIRED, restarted.status(id).status()); assertEquals(1, world.writes);
|
||||
assertEquals("matches_after", restarted.inspectRecovery(id, 0, 1).get(0).reason());
|
||||
assertTrue(restarted.stageSlice(id).isEmpty()); assertEquals(1, world.writes);
|
||||
}
|
||||
@Test void crashBeforeWriteRecognizesBeforeAndForeignStatesWithoutChangingEither() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); JsonJournal journal = new JsonJournal(temporary); EditEngine engine = engine(world, journal);
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE)));
|
||||
SliceIntent intent = nextIntent(engine, id); engine.persistIntent(intent);
|
||||
EditEngine restarted = engine(world, new JsonJournal(temporary));
|
||||
assertEquals("matches_before", restarted.inspectRecovery(id, 0, 1).get(0).reason());
|
||||
world.blocks.put(A, GOLD); assertEquals("foreign_state", restarted.inspectRecovery(id, 0, 1).get(0).reason());
|
||||
assertEquals(0, world.writes);
|
||||
}
|
||||
@Test void worldSetterThrowAfterMutationPreservesConfirmedReceiptForUndo() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
world.afterWrite = () -> { throw new IllegalStateException("post-write callback failed"); };
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE)));
|
||||
OperationView result = finish(engine, id);
|
||||
assertEquals(OperationStatus.FAILED, result.status()); assertEquals(1, result.written());
|
||||
world.afterWrite = null; Plan undo = engine.prepareUndo(id); engine.persistPlan(undo.id()); finish(engine, start(engine, undo));
|
||||
assertEquals(AIR, world.getBlock(A));
|
||||
}
|
||||
@Test void nestedWorldCallbackCannotOverwriteLaterPositionInSameSlice() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld();
|
||||
EditEngine engine = new EditEngine(world, state -> true, plan -> {}, new MemoryJournal(),
|
||||
new Limits(10, 10, 10, 1_000_000_000, 600_000, 4));
|
||||
world.afterWrite = () -> world.blocks.put(B, GOLD);
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE)));
|
||||
OperationView result = finish(engine, id);
|
||||
assertEquals(OperationStatus.CONFLICT, result.status()); assertEquals(1, result.written()); assertEquals(GOLD, world.getBlock(B));
|
||||
}
|
||||
@Test void finalAuditDetectsChangeToAlreadyWrittenBlock() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE)));
|
||||
slice(engine, id); slice(engine, id); world.blocks.put(A, GOLD);
|
||||
assertEquals(OperationStatus.CONFLICT, finish(engine, id).status()); assertEquals(GOLD, world.getBlock(A));
|
||||
}
|
||||
@Test void returnedPlansAndIntentsAreImmutable() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
Map<BlockPos, String> desired = new LinkedHashMap<>(Map.of(A, STONE)); Plan plan = prepare(engine, desired);
|
||||
desired.put(B, GOLD); assertEquals(1, plan.changes().size());
|
||||
assertThrows(UnsupportedOperationException.class, () -> plan.changes().clear());
|
||||
SliceIntent intent = nextIntent(engine, start(engine, plan));
|
||||
assertThrows(UnsupportedOperationException.class, () -> intent.changes().clear());
|
||||
SliceIntent copy = new SliceIntent(intent.operationId(), intent.sequence(), intent.changes());
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.persistIntent(copy));
|
||||
}
|
||||
|
||||
@Test void stalledJournalDoesNotBlockStatusOrCancelAndDoesNotLoseCancellation() throws Exception {
|
||||
CountDownLatch saving = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
MemoryJournal journal = new MemoryJournal() {
|
||||
@Override public void saveOperation(OperationSnapshot op) throws IOException {
|
||||
saving.countDown();
|
||||
try { if (!release.await(5, TimeUnit.SECONDS)) throw new IOException("Test journal timeout"); }
|
||||
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException(e); }
|
||||
super.saveOperation(op);
|
||||
}
|
||||
};
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, journal);
|
||||
Plan plan = prepare(engine, Map.of(A, STONE)); String id = engine.start(plan.id(), "request").id();
|
||||
CompletableFuture<Void> flushing = CompletableFuture.runAsync(() -> {
|
||||
try { engine.flushOperation(id); } catch (IOException e) { throw new RuntimeException(e); }
|
||||
});
|
||||
assertTrue(saving.await(2, TimeUnit.SECONDS));
|
||||
try {
|
||||
var cancellation = CompletableFuture.supplyAsync(() -> { engine.status(id); return engine.cancel(id); });
|
||||
assertTrue(cancellation.get(1, TimeUnit.SECONDS).cancellationRequested());
|
||||
} finally { release.countDown(); }
|
||||
flushing.get(2, TimeUnit.SECONDS);
|
||||
assertTrue(engine.status(id).needsFlush());
|
||||
engine.flushOperation(id);
|
||||
assertEquals(OperationStatus.CANCELLED, finish(engine, id).status()); assertEquals(0, world.writes);
|
||||
}
|
||||
|
||||
@Test void storedPlanIsRecheckedAgainstChangedBlockPolicy() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal();
|
||||
Plan plan = prepare(engine(world, journal), Map.of(A, STONE));
|
||||
EditEngine restricted = new EditEngine(world, state -> AIR.equals(state), ignored -> {}, journal, LIMITS);
|
||||
assertEquals(OperationStatus.CONFLICT, finish(restricted, start(restricted, plan)).status());
|
||||
assertEquals(0, world.writes);
|
||||
}
|
||||
|
||||
@Test void pendingIndexRetainsDirtyTerminalStateUntilFlushAndExcludesFinishedHistory() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
Plan first = prepare(engine, Map.of());
|
||||
String firstId = engine.start(first.id(), "empty").id();
|
||||
assertEquals(OperationStatus.APPLIED, engine.status(firstId).status());
|
||||
assertEquals(List.of(firstId), engine.pendingOperations().stream().map(OperationView::id).toList());
|
||||
engine.flushOperation(firstId); assertTrue(engine.pendingOperations().isEmpty());
|
||||
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE)));
|
||||
assertEquals(List.of(id), engine.pendingOperations().stream().map(OperationView::id).toList());
|
||||
engine.cancel(id); engine.stageSlice(id);
|
||||
assertEquals(OperationStatus.CANCELLED, engine.pendingOperations().get(0).status());
|
||||
engine.flushOperation(id); assertTrue(engine.pendingOperations().isEmpty());
|
||||
assertEquals(2, engine.operationCount());
|
||||
}
|
||||
|
||||
@Test void recentContextAndSchedulerRemainBoundedWithLargeCompletedHistory() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
List<String> ids = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < 150; i++) ids.add(start(engine, prepare(engine, Map.of())));
|
||||
assertEquals(150, engine.operationCount()); assertTrue(engine.pendingOperations().isEmpty());
|
||||
assertEquals(7, engine.recentOperations(7).size());
|
||||
assertEquals(ids.get(149), engine.recentOperations(7).get(0).id());
|
||||
assertEquals(ids.get(143), engine.recentOperations(7).get(6).id());
|
||||
assertTrue(engine.recentOperations(0).isEmpty());
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.recentOperations(101));
|
||||
}
|
||||
|
||||
@Test void restartedIndexesIncludeUnflushedRecoveryWithoutAllTerminalHistory() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal();
|
||||
EditEngine engine = engine(world, journal);
|
||||
start(engine, prepare(engine, Map.of()));
|
||||
String active = start(engine, prepare(engine, Map.of(A, STONE)));
|
||||
EditEngine restarted = engine(world, journal);
|
||||
assertEquals(2, restarted.operationCount()); assertEquals(2, restarted.recentOperations(10).size());
|
||||
assertEquals(List.of(active), restarted.pendingOperations().stream().map(OperationView::id).toList());
|
||||
assertEquals(OperationStatus.RECOVERY_REQUIRED, restarted.pendingOperations().get(0).status());
|
||||
restarted.flushOperation(active); assertTrue(restarted.pendingOperations().isEmpty());
|
||||
Plan other = prepare(restarted, Map.of(B, STONE));
|
||||
assertThrows(IllegalStateException.class, () -> restarted.start(other.id(), "still-blocked"));
|
||||
}
|
||||
|
||||
@Test void observedExternalAbaRejectsUndoEvenWhenStateReturnsToDesired() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
String source = start(engine, prepare(engine, Map.of(A, STONE))); finish(engine, source);
|
||||
world.blocks.put(A, AIR); engine.recordExternal(A);
|
||||
world.blocks.put(A, STONE); engine.recordExternal(A);
|
||||
assertThrows(IllegalStateException.class, () -> engine.prepareUndo(source));
|
||||
assertEquals(1, world.writes); assertEquals(STONE, world.getBlock(A));
|
||||
}
|
||||
|
||||
@Test void observedExternalAbaDuringUndoIntentPersistenceIsRecheckedAtCommit() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
String source = start(engine, prepare(engine, Map.of(A, STONE))); finish(engine, source);
|
||||
Plan undo = engine.prepareUndo(source); engine.persistPlan(undo.id());
|
||||
String inverse = start(engine, undo); SliceIntent intent = nextIntent(engine, inverse); engine.persistIntent(intent);
|
||||
world.blocks.put(A, AIR); engine.recordExternal(A); world.blocks.put(A, STONE); engine.recordExternal(A);
|
||||
assertEquals(OperationStatus.CONFLICT, engine.commitSlice(intent).status());
|
||||
assertEquals(1, world.writes); assertEquals(STONE, world.getBlock(A));
|
||||
}
|
||||
|
||||
@Test void externalNotificationForUnownedPositionDoesNotAffectUnrelatedUndo() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal());
|
||||
String source = start(engine, prepare(engine, Map.of(A, STONE))); finish(engine, source);
|
||||
engine.recordExternal(B);
|
||||
Plan undo = engine.prepareUndo(source); engine.persistPlan(undo.id());
|
||||
assertEquals(OperationStatus.APPLIED, finish(engine, start(engine, undo)).status());
|
||||
assertEquals(AIR, world.getBlock(A));
|
||||
}
|
||||
|
||||
@Test void recoveryInspectsEarlierConfirmedSlicesAndUncertainCurrentSliceTogether() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine engine = engine(world, journal);
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE)));
|
||||
slice(engine, id);
|
||||
SliceIntent second = nextIntent(engine, id); engine.persistIntent(second);
|
||||
EditEngine restarted = engine(world, journal);
|
||||
assertEquals(A, restarted.inspectRecovery(id, 0, 1).get(0).pos());
|
||||
assertEquals("matches_after", restarted.inspectRecovery(id, 0, 1).get(0).reason());
|
||||
assertEquals(B, restarted.inspectRecovery(id, 1, 1).get(0).pos());
|
||||
assertEquals("matches_before", restarted.inspectRecovery(id, 1, 1).get(0).reason());
|
||||
assertEquals(1, world.writes);
|
||||
}
|
||||
|
||||
@Test void crashInsideSliceRecognizesMixedWorldStatesWithoutAssumingAllWritesHappened() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal();
|
||||
EditEngine engine = new EditEngine(world, state -> true, ignored -> {}, journal,
|
||||
new Limits(10, 10, 10, 1_000_000_000, 600_000, 4));
|
||||
String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE)));
|
||||
SliceIntent intent = nextIntent(engine, id); engine.persistIntent(intent);
|
||||
// Model process death after one write but before any in-memory/durable receipt can be trusted.
|
||||
world.setBlock(A, STONE);
|
||||
EditEngine restarted = engine(world, journal);
|
||||
assertEquals(OperationStatus.RECOVERY_REQUIRED, restarted.status(id).status());
|
||||
assertEquals("matches_after", restarted.inspectRecovery(id, 0, 1).get(0).reason());
|
||||
assertEquals("matches_before", restarted.inspectRecovery(id, 1, 1).get(0).reason());
|
||||
assertEquals(0, restarted.status(id).written()); assertEquals(1, world.writes);
|
||||
}
|
||||
|
||||
@Test void controlCharactersCannotCrossIdempotencyProjectScope() throws Exception {
|
||||
EditEngine engine = engine(new MemoryWorld(), new MemoryJournal());
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> engine.prepare("project\u0000key", "epoch", REGION, Map.of(), Set.of()));
|
||||
Plan plan = prepare(engine, Map.of());
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.start(plan.id(), "key\u0000suffix"));
|
||||
}
|
||||
|
||||
@Test void recoveryAbandonRequiresUnchangedReviewedMaskAndDoesNotWriteWorld() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine initial = engine(world, journal);
|
||||
String id = start(initial, prepare(initial, Map.of(A, STONE, B, STONE)));
|
||||
slice(initial, id); SliceIntent second = nextIntent(initial, id); initial.persistIntent(second);
|
||||
EditEngine recovered = engine(world, journal);
|
||||
RecoveryReview review = recovered.reviewRecovery(id);
|
||||
assertEquals(2, review.positions()); assertEquals(1, review.matchesBefore()); assertEquals(1, review.matchesAfter());
|
||||
assertEquals(review.currentDigest(), recovered.reviewRecovery(id).currentDigest());
|
||||
world.blocks.put(B, "minecraft:chest");
|
||||
assertThrows(IllegalStateException.class, () -> recovered.abandonRecovery(id, review.currentDigest()));
|
||||
assertEquals(OperationStatus.RECOVERY_REQUIRED, recovered.status(id).status());
|
||||
RecoveryReview changed = recovered.reviewRecovery(id); assertEquals(1, changed.foreignStates());
|
||||
OperationView abandoned = recovered.abandonRecovery(id, changed.currentDigest());
|
||||
assertEquals(OperationStatus.FAILED, abandoned.status()); assertTrue(abandoned.needsFlush());
|
||||
assertEquals(1, world.writes); assertEquals("minecraft:chest", world.getBlock(B));
|
||||
Plan next = prepare(recovered, Map.of(C, STONE));
|
||||
assertThrows(IllegalStateException.class, () -> recovered.start(next.id(), "before-abandonment-flush"));
|
||||
recovered.flushOperation(id);
|
||||
assertThrows(IllegalStateException.class, () -> recovered.prepareUndo(id));
|
||||
assertEquals(OperationStatus.APPLIED, finish(recovered, start(recovered, next)).status());
|
||||
assertEquals("minecraft:chest", world.getBlock(B));
|
||||
}
|
||||
|
||||
@Test void crashBeforeAbandonmentFlushRetainsRecoveryLatchAndOriginalMask() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); JsonJournal journal = new JsonJournal(temporary); EditEngine initial = engine(world, journal);
|
||||
String id = start(initial, prepare(initial, Map.of(A, STONE)));
|
||||
SliceIntent intent = nextIntent(initial, id); initial.persistIntent(intent);
|
||||
EditEngine recovered = engine(world, new JsonJournal(temporary));
|
||||
recovered.abandonRecovery(id, recovered.reviewRecovery(id).currentDigest());
|
||||
EditEngine crashedAgain = engine(world, new JsonJournal(temporary));
|
||||
assertEquals(OperationStatus.RECOVERY_REQUIRED, crashedAgain.status(id).status());
|
||||
assertEquals(1, crashedAgain.reviewRecovery(id).positions());
|
||||
Plan next = prepare(crashedAgain, Map.of(B, STONE));
|
||||
assertThrows(IllegalStateException.class, () -> crashedAgain.start(next.id(), "still-unresolved"));
|
||||
assertEquals(0, world.writes);
|
||||
}
|
||||
|
||||
@Test void failedAbandonmentFlushKeepsMaskForAnotherExplicitReview() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine initial = engine(world, journal);
|
||||
String id = start(initial, prepare(initial, Map.of(A, STONE, B, STONE)));
|
||||
slice(initial, id); SliceIntent second = nextIntent(initial, id); initial.persistIntent(second);
|
||||
EditEngine recovered = engine(world, journal);
|
||||
recovered.abandonRecovery(id, recovered.reviewRecovery(id).currentDigest());
|
||||
journal.fail = true; assertThrows(IOException.class, () -> recovered.flushOperation(id));
|
||||
assertEquals(OperationStatus.RECOVERY_REQUIRED, recovered.status(id).status());
|
||||
assertEquals(2, recovered.reviewRecovery(id).positions());
|
||||
journal.fail = false;
|
||||
// Even a persisted recovery marker carrying prior abandonment metadata must remain blocked.
|
||||
recovered.flushOperation(id);
|
||||
EditEngine restarted = engine(world, journal);
|
||||
assertEquals(OperationStatus.RECOVERY_REQUIRED, restarted.status(id).status());
|
||||
assertEquals(2, restarted.reviewRecovery(id).positions());
|
||||
restarted.abandonRecovery(id, restarted.reviewRecovery(id).currentDigest()); restarted.flushOperation(id);
|
||||
assertEquals(OperationStatus.APPLIED, finish(restarted, start(restarted, prepare(restarted, Map.of(C, STONE)))).status());
|
||||
}
|
||||
|
||||
@Test void durableAbandonmentInvalidatesEarlierOwnershipAcrossRestartButAllowsNewHistory() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); JsonJournal journal = new JsonJournal(temporary); EditEngine initial = engine(world, journal);
|
||||
String first = start(initial, prepare(initial, Map.of(A, STONE))); finish(initial, first);
|
||||
String interrupted = start(initial, prepare(initial, Map.of(A, GOLD)));
|
||||
SliceIntent intent = nextIntent(initial, interrupted); initial.persistIntent(intent);
|
||||
EditEngine recovered = engine(world, new JsonJournal(temporary));
|
||||
recovered.abandonRecovery(interrupted, recovered.reviewRecovery(interrupted).currentDigest()); recovered.flushOperation(interrupted);
|
||||
assertThrows(IllegalStateException.class, () -> recovered.prepareUndo(first));
|
||||
EditEngine restarted = engine(world, new JsonJournal(temporary));
|
||||
assertEquals(OperationStatus.FAILED, restarted.status(interrupted).status());
|
||||
assertTrue(restarted.pendingOperations().isEmpty());
|
||||
assertThrows(IllegalStateException.class, () -> restarted.prepareUndo(first));
|
||||
assertThrows(IllegalStateException.class, () -> restarted.prepareUndo(interrupted));
|
||||
String latest = start(restarted, prepare(restarted, Map.of(A, GOLD))); finish(restarted, latest);
|
||||
EditEngine lastRestart = engine(world, new JsonJournal(temporary));
|
||||
Plan undoLatest = lastRestart.prepareUndo(latest); lastRestart.persistPlan(undoLatest.id());
|
||||
assertEquals(OperationStatus.APPLIED, finish(lastRestart, start(lastRestart, undoLatest)).status());
|
||||
assertEquals(STONE, world.getBlock(A));
|
||||
}
|
||||
|
||||
@Test void digestCannotAbandonAnotherOperationWithSameBlockContents() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); MemoryJournal firstJournal = new MemoryJournal(), secondJournal = new MemoryJournal();
|
||||
EditEngine first = engine(world, firstJournal), second = engine(world, secondJournal);
|
||||
String firstId = start(first, prepare(first, Map.of(A, STONE)));
|
||||
String secondId = start(second, prepare(second, Map.of(A, STONE)));
|
||||
first.persistIntent(nextIntent(first, firstId)); second.persistIntent(nextIntent(second, secondId));
|
||||
EditEngine recoveredFirst = engine(world, firstJournal), recoveredSecond = engine(world, secondJournal);
|
||||
String firstDigest = recoveredFirst.reviewRecovery(firstId).currentDigest();
|
||||
assertThrows(IllegalStateException.class, () -> recoveredSecond.abandonRecovery(secondId, firstDigest));
|
||||
assertEquals(OperationStatus.RECOVERY_REQUIRED, recoveredSecond.status(secondId).status());
|
||||
assertEquals(0, world.writes);
|
||||
}
|
||||
|
||||
@Test void queuedOperationWithNoIntentCanBeExplicitlyAbandonedWithoutInventingWrites() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine initial = engine(world, journal);
|
||||
String id = start(initial, prepare(initial, Map.of(A, STONE)));
|
||||
EditEngine recovered = engine(world, journal);
|
||||
RecoveryReview review = recovered.reviewRecovery(id); assertEquals(0, review.positions());
|
||||
recovered.abandonRecovery(id, review.currentDigest()); recovered.flushOperation(id);
|
||||
assertEquals(0, world.writes);
|
||||
assertEquals(OperationStatus.APPLIED, finish(recovered, start(recovered, prepare(recovered, Map.of(B, STONE)))).status());
|
||||
}
|
||||
|
||||
@Test void oldRecoveryFlushCannotAcknowledgeNewerAbandonment() throws Exception {
|
||||
AtomicBoolean block = new AtomicBoolean(false);
|
||||
CountDownLatch saving = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
MemoryJournal journal = new MemoryJournal() {
|
||||
@Override public void saveOperation(OperationSnapshot op) throws IOException {
|
||||
if (block.get()) {
|
||||
saving.countDown();
|
||||
try { if (!release.await(5, TimeUnit.SECONDS)) throw new IOException("Test journal timeout"); }
|
||||
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException(e); }
|
||||
}
|
||||
super.saveOperation(op);
|
||||
}
|
||||
};
|
||||
MemoryWorld world = new MemoryWorld(); EditEngine initial = engine(world, journal);
|
||||
String id = start(initial, prepare(initial, Map.of(A, STONE)));
|
||||
initial.persistIntent(nextIntent(initial, id));
|
||||
EditEngine recovered = engine(world, journal); block.set(true);
|
||||
CompletableFuture<Void> oldFlush = CompletableFuture.runAsync(() -> {
|
||||
try { recovered.flushOperation(id); } catch (IOException e) { throw new RuntimeException(e); }
|
||||
});
|
||||
assertTrue(saving.await(2, TimeUnit.SECONDS));
|
||||
try { recovered.abandonRecovery(id, recovered.reviewRecovery(id).currentDigest()); }
|
||||
finally { release.countDown(); }
|
||||
oldFlush.get(2, TimeUnit.SECONDS); block.set(false);
|
||||
assertTrue(recovered.status(id).needsFlush());
|
||||
Plan next = prepare(recovered, Map.of(B, STONE));
|
||||
assertThrows(IllegalStateException.class, () -> recovered.start(next.id(), "stale-flush"));
|
||||
recovered.flushOperation(id);
|
||||
assertEquals(OperationStatus.APPLIED, finish(recovered, start(recovered, next)).status());
|
||||
}
|
||||
|
||||
@Test void EveryUnresolvedOperationMustBeDurablyAbandonedBeforeNewWrites() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); MemoryJournal firstJournal = new MemoryJournal(), secondJournal = new MemoryJournal();
|
||||
EditEngine first = engine(world, firstJournal), second = engine(world, secondJournal);
|
||||
String firstId = start(first, prepare(first, Map.of(A, STONE)));
|
||||
String secondId = start(second, prepare(second, Map.of(B, STONE)));
|
||||
first.persistIntent(nextIntent(first, firstId)); second.persistIntent(nextIntent(second, secondId));
|
||||
firstJournal.plans.putAll(secondJournal.plans); firstJournal.operations.putAll(secondJournal.operations);
|
||||
EditEngine recovered = engine(world, firstJournal);
|
||||
recovered.abandonRecovery(firstId, recovered.reviewRecovery(firstId).currentDigest()); recovered.flushOperation(firstId);
|
||||
Plan next = prepare(recovered, Map.of(C, STONE));
|
||||
assertThrows(IllegalStateException.class, () -> recovered.start(next.id(), "other-unresolved"));
|
||||
recovered.abandonRecovery(secondId, recovered.reviewRecovery(secondId).currentDigest()); recovered.flushOperation(secondId);
|
||||
assertEquals(OperationStatus.APPLIED, finish(recovered, start(recovered, next)).status());
|
||||
}
|
||||
|
||||
@Test void recoveryCanAbandonWithWritesPausedWhileStillEnforcingContextRights() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine initial = engine(world, journal);
|
||||
String id = start(initial, prepare(initial, Map.of(A, STONE)));
|
||||
initial.persistIntent(nextIntent(initial, id));
|
||||
AtomicBoolean contextAuthorized = new AtomicBoolean(true);
|
||||
ContextGuard guard = new ContextGuard() {
|
||||
private void context(Plan plan) {
|
||||
if (!contextAuthorized.get() || !plan.projectId().equals("project") || !plan.worldEpoch().equals("epoch")
|
||||
|| !plan.region().equals(REGION)) throw new SecurityException("Context or owner no longer authorized");
|
||||
}
|
||||
@Override public void check(Plan plan) {
|
||||
context(plan); throw new IllegalStateException("Writes paused or part protected");
|
||||
}
|
||||
@Override public void checkRecovery(Plan plan) { context(plan); }
|
||||
};
|
||||
EditEngine recovered = new EditEngine(world, state -> true, guard, journal, LIMITS);
|
||||
RecoveryReview review = recovered.reviewRecovery(id);
|
||||
assertEquals(1, recovered.inspectRecovery(id, 0, 1).size());
|
||||
contextAuthorized.set(false);
|
||||
assertThrows(SecurityException.class, () -> recovered.reviewRecovery(id));
|
||||
assertThrows(SecurityException.class, () -> recovered.inspectRecovery(id, 0, 1));
|
||||
assertThrows(SecurityException.class, () -> recovered.abandonRecovery(id, review.currentDigest()));
|
||||
assertEquals(OperationStatus.RECOVERY_REQUIRED, recovered.status(id).status());
|
||||
contextAuthorized.set(true);
|
||||
recovered.abandonRecovery(id, review.currentDigest()); recovered.flushOperation(id);
|
||||
assertEquals(OperationStatus.FAILED, recovered.status(id).status()); assertEquals(0, world.writes);
|
||||
assertThrows(IllegalStateException.class, () -> prepare(recovered, Map.of(B, STONE)));
|
||||
}
|
||||
|
||||
@Test void recoveryGuardDefaultNeverSilentlyBypassesExistingAuthorization() throws Exception {
|
||||
MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine initial = engine(world, journal);
|
||||
String id = start(initial, prepare(initial, Map.of(A, STONE)));
|
||||
initial.persistIntent(nextIntent(initial, id));
|
||||
EditEngine restricted = new EditEngine(world, state -> true,
|
||||
plan -> { throw new SecurityException("World epoch changed"); }, journal, LIMITS);
|
||||
assertThrows(SecurityException.class, () -> restricted.reviewRecovery(id));
|
||||
assertThrows(SecurityException.class, () -> restricted.inspectRecovery(id, 0, 1));
|
||||
assertEquals(OperationStatus.RECOVERY_REQUIRED, restricted.status(id).status());
|
||||
assertEquals(0, world.writes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import com.google.gson.JsonParser;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JsonJournalTest {
|
||||
@TempDir Path temporary;
|
||||
@Test void roundTripsDurablePlanAndDetectsContentCorruption() throws Exception {
|
||||
JsonJournal journal = new JsonJournal(temporary);
|
||||
var engine = EditEngineTest.engine(new EditEngineTest.MemoryWorld(), journal);
|
||||
Plan plan = EditEngineTest.prepare(engine, Map.of(EditEngineTest.A, EditEngineTest.STONE));
|
||||
assertEquals(plan, new JsonJournal(temporary).loadPlans().get(0));
|
||||
Path file = temporary.resolve("plans").resolve(plan.id() + ".json");
|
||||
Files.writeString(file, Files.readString(file).replace("minecraft:stone", "minecraft:dirt"));
|
||||
assertThrows(IOException.class, () -> new JsonJournal(temporary).loadPlans());
|
||||
}
|
||||
@Test void strayTemporaryFileDoesNotBecomeACommittedRecord() throws Exception {
|
||||
JsonJournal journal = new JsonJournal(temporary);
|
||||
Files.writeString(temporary.resolve("operations/.pending-killed.tmp"), "half a record");
|
||||
assertTrue(journal.loadOperations().isEmpty());
|
||||
}
|
||||
@Test void malformedCommittedFileFailsClosed() throws Exception {
|
||||
JsonJournal journal = new JsonJournal(temporary);
|
||||
Files.writeString(temporary.resolve("operations/bad.json"), "{truncated");
|
||||
assertThrows(IOException.class, journal::loadOperations);
|
||||
}
|
||||
|
||||
@Test void originalVersionOneOperationWithoutAbandonmentFieldsStillLoads() throws Exception {
|
||||
JsonJournal journal = new JsonJournal(temporary);
|
||||
var world = new EditEngineTest.MemoryWorld(); var engine = EditEngineTest.engine(world, journal);
|
||||
Plan plan = EditEngineTest.prepare(engine, Map.of(EditEngineTest.A, EditEngineTest.STONE));
|
||||
String id = EditEngineTest.start(engine, plan);
|
||||
Path path = temporary.resolve("operations").resolve(id + ".json");
|
||||
var envelope = JsonParser.parseString(Files.readString(path)).getAsJsonObject();
|
||||
var payload = JsonParser.parseString(envelope.get("payload").getAsString()).getAsJsonObject();
|
||||
payload.remove("abandonedRevision"); payload.remove("abandonedPositions");
|
||||
String originalPayload = payload.toString();
|
||||
envelope.addProperty("payload", originalPayload);
|
||||
envelope.addProperty("sha256", HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(originalPayload.getBytes(StandardCharsets.UTF_8))));
|
||||
Files.writeString(path, envelope.toString());
|
||||
var restarted = EditEngineTest.engine(world, new JsonJournal(temporary));
|
||||
assertEquals(OperationStatus.RECOVERY_REQUIRED, restarted.status(id).status());
|
||||
assertEquals(0, restarted.reviewRecovery(id).positions());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.Map;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RecipeCompilerTest {
|
||||
private static Map<BlockPos, String> compile(String operations, int limit) {
|
||||
return RecipeCompiler.compile(JsonParser.parseString("{\"version\":1,\"operations\":" + operations + "}").getAsJsonObject(), limit);
|
||||
}
|
||||
@Test void hollowBoxHasFacesAndNoInterior() {
|
||||
var blocks = compile("""
|
||||
[{"type":"box","min":{"x":0,"y":0,"z":0},"max":{"x":2,"y":2,"z":2},"block":"minecraft:stone","hollow":true}]
|
||||
""", 100);
|
||||
assertEquals(26, blocks.size()); assertFalse(blocks.containsKey(new BlockPos(1, 1, 1)));
|
||||
}
|
||||
@Test void repeatUsesOffsetsAndLaterOperationsWinDeterministically() {
|
||||
String json = """
|
||||
[{"type":"repeat","count":3,"offset":{"x":2,"y":0,"z":0},"operations":[
|
||||
{"type":"line","from":{"x":0,"y":0,"z":0},"to":{"x":1,"y":0,"z":0},"block":"minecraft:stone"}
|
||||
]},{"type":"box","min":{"x":2,"y":0,"z":0},"max":{"x":2,"y":0,"z":0},"block":"minecraft:gold_block"}]
|
||||
""";
|
||||
var blocks = compile(json, 20); assertEquals(6, blocks.size());
|
||||
assertEquals("minecraft:gold_block", blocks.get(new BlockPos(2, 0, 0))); assertEquals(blocks, compile(json, 20));
|
||||
}
|
||||
@Test void descendingDiagonalIncludesExactEndpoints() {
|
||||
var blocks = compile("""
|
||||
[{"type":"line","from":{"x":3,"y":3,"z":3},"to":{"x":-2,"y":-2,"z":-2},"block":"minecraft:stone"}]
|
||||
""", 10);
|
||||
assertEquals(6, blocks.size()); assertTrue(blocks.containsKey(new BlockPos(-2, -2, -2)));
|
||||
assertTrue(blocks.containsKey(new BlockPos(3, 3, 3)));
|
||||
}
|
||||
@Test void cylinderUsesBottomCenterAndOpenShell() {
|
||||
var solid = compile("""
|
||||
[{"type":"cylinder","center":{"x":0,"y":3,"z":0},"radius":1,"height":2,"block":"minecraft:stone"}]
|
||||
""", 20);
|
||||
assertEquals(10, solid.size()); assertTrue(solid.containsKey(new BlockPos(0, 4, 0)));
|
||||
var hollow = compile("""
|
||||
[{"type":"cylinder","center":{"x":0,"y":3,"z":0},"radius":1,"height":2,"block":"minecraft:stone","hollow":true}]
|
||||
""", 20);
|
||||
assertEquals(8, hollow.size()); assertFalse(hollow.containsKey(new BlockPos(0, 3, 0)));
|
||||
}
|
||||
@Test void boundsAndScanBudgetRejectHugeOrOverlappingRecipes() {
|
||||
assertThrows(IllegalArgumentException.class, () -> compile("""
|
||||
[{"type":"box","min":{"x":0,"y":0,"z":0},"max":{"x":1000,"y":1000,"z":1000},"block":"minecraft:stone"}]
|
||||
""", 100));
|
||||
assertThrows(IllegalArgumentException.class, () -> compile("""
|
||||
[{"type":"repeat","count":100,"offset":{"x":0,"y":0,"z":0},"operations":[
|
||||
{"type":"box","min":{"x":0,"y":0,"z":0},"max":{"x":0,"y":0,"z":0},"block":"minecraft:stone"}]}]
|
||||
""", 1));
|
||||
assertThrows(IllegalArgumentException.class, () -> compile("""
|
||||
[{"type":"line","from":{"x":0,"y":0,"z":0},"to":{"x":2,"y":0,"z":0},"block":"minecraft:stone"}]
|
||||
""", 2));
|
||||
}
|
||||
@Test void rejectsFractionalCoordinatesUnknownFieldsAndCoordinateOverflow() {
|
||||
assertThrows(IllegalArgumentException.class, () -> compile("""
|
||||
[{"type":"line","from":{"x":0.5,"y":0,"z":0},"to":{"x":1,"y":0,"z":0},"block":"minecraft:stone"}]
|
||||
""", 20));
|
||||
assertThrows(IllegalArgumentException.class, () -> compile("""
|
||||
[{"type":"repeat","count":1,"offset":{"x":0,"y":0,"z":0},"operations":[],"script":"shell"}]
|
||||
""", 20));
|
||||
assertThrows(ArithmeticException.class, () -> compile("""
|
||||
[{"type":"repeat","count":2,"offset":{"x":1,"y":0,"z":0},"operations":[
|
||||
{"type":"box","min":{"x":2147483647,"y":0,"z":0},"max":{"x":2147483647,"y":0,"z":0},"block":"minecraft:stone"}]}]
|
||||
""", 20));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user