diff --git a/build.gradle.kts b/build.gradle.kts index 2425b24..8b636dd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } group = "org.inventivetalent.tessera" -version = "26.6.1" +version = "26.7.0-SNAPSHOT" java { toolchain.languageVersion.set(JavaLanguageVersion.of(25)) diff --git a/docs/api.md b/docs/api.md index 555ba14..dcab0f6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -136,6 +136,69 @@ public void onBaked(TesseraBlockBakedEvent event) { } ``` +## Replacing the break animation (API v2) + +A plugin can take over the animation Tessera plays when a block breaks by +registering a **break-effect provider**. Tessera still does everything up to +that moment — gating, variant rotation, lattice spawning, the progress-driven +mining wave, per-viewer transport — and then offers the surviving fragments to +your provider instead of running its built-in collapse: + +```java +TesseraApi tessera = Tessera.api(); +if (tessera == null || tessera.apiVersion() < 2) { + getLogger().severe("Tessera with API v2 required"); + return; +} +tessera.registerBreakEffectProvider(this, new BreakEffectProvider() { + @Override + public boolean onBlockShatter(ShatterHandle handle, ShatterContext ctx) { + for (ShatterChunk chunk : handle.chunks()) { + // animate: chunk.setPose(centerOffset, tumble, scale, delay, interp) + } + // you now own the fragments — despawn when the effect is done: + Bukkit.getScheduler().runTaskLater(plugin, handle::despawn, 40L); + return true; // claimed; false = let the built-in effect run + } + + @Override + public boolean wantsInteriorFill() { + return true; // solid lattice even if animation.fillInterior is off + } +}); +``` + +The contract, briefly: + +- **One slot, first-wins.** A second plugin's registration fails with a log + line; two providers fighting over the same break can't be reconciled. The + provider is detached automatically when its plugin disables. +- **Claim = own the despawn.** Return `true` and you must eventually call + `handle.despawn()` — that removes the fragments and frees Tessera's + concurrency slot. As a backstop, Tessera force-despawns a claimed break + after `limits.providerMaxEffectDurationMs` (config, default 10s); + `despawn()` is idempotent so a well-behaved provider never notices. +- **Decline = built-in effect.** Return `false` (or throw — logged once) and + Tessera animates the break exactly as if no provider were registered. +- **Progress mode hands over the remainder.** With the default + `animation.mode: progress`, the mining wave consumes fragments while the + player digs; on the real break your provider receives only what survived, + positioned and scaled mid-wave. `ShatterContext.mode()` tells you which + path you're on. +- **Pose, don't recompute.** `ShatterChunk.setPose(centerOffset, tumble, + scale, delayTicks, durationTicks)` places a fragment's cube center relative + to `handle.origin()` with a world-space rotation on top of the blockstate + orientation. The player-head model's render-offset compensation is + re-solved internally — with the raw `setTransformation` escape hatch, + rotating the left rotation makes fragments orbit off-center. A positive + `durationTicks` rides the client's display interpolation, so a handful of + poses per second animates smoothly with no per-tick server work. +- **Main thread only**, like the rest of the break pipeline. Fragments are + visible only to the breaker (`ShatterContext.breaker()`). + +In-game check: `/tessera test stone` routes through the registered provider; +`/tessera test stone builtin` forces the built-in effect for comparison. + ## Versioning `TesseraApi.VERSION` (and `apiVersion()`) is bumped on incompatible changes. The diff --git a/src/main/java/org/inventivetalent/tessera/assemble/BlockGeometry.java b/src/main/java/org/inventivetalent/tessera/assemble/BlockGeometry.java index 0ed977f..762be26 100644 --- a/src/main/java/org/inventivetalent/tessera/assemble/BlockGeometry.java +++ b/src/main/java/org/inventivetalent/tessera/assemble/BlockGeometry.java @@ -142,4 +142,33 @@ public Vector3f translationFor(ChunkCoord coord, Quaternionf faceRot, float over public Quaternionf blockRotation() { return new Quaternionf(blockRotation); } + + /** + * Translation that puts a chunk cube's geometric center at + * {@code worldCenterOffset} (relative to the display's entity location) + * for an arbitrary left rotation — unlike + * {@link #translationFor(ChunkCoord, Quaternionf, float)}, which assumes + * the left rotation is this geometry's block rotation and derives the + * center from the grid cell. + * + *

{@code worldVertex = entityLocation + T + L*S*R*v}, so the rendered + * cube center sits at {@code T + L*S*R*CUBE_CENTER_PRE}. Solving for the + * requested center: {@code T = worldCenterOffset - L*(R*CUBE_CENTER_PRE)*s}. + * Callers animating a chunk (moving or spinning it after spawn) must + * recompute {@code T} with this whenever {@code L} or the scale changes, + * or the cube orbits its own center instead of rotating in place. + * + *

Pure function of its arguments plus the tunable + * {@link #cubeCenterPre()}; safe from any thread that isn't concurrently + * re-tuning the center via {@code /tessera debug center}. + */ + public static Vector3f poseTranslation(Vector3f worldCenterOffset, + Quaternionf leftRotation, + Quaternionf faceRot, + float scale) { + Vector3f disp = new Quaternionf(faceRot).transform(new Vector3f(CUBE_CENTER_PRE)); + disp.mul(scale); + new Quaternionf(leftRotation).transform(disp); + return new Vector3f(worldCenterOffset).sub(disp); + } } diff --git a/src/main/java/org/inventivetalent/tessera/core/FakeBlock.java b/src/main/java/org/inventivetalent/tessera/core/FakeBlock.java index b352ce2..a845af9 100644 --- a/src/main/java/org/inventivetalent/tessera/core/FakeBlock.java +++ b/src/main/java/org/inventivetalent/tessera/core/FakeBlock.java @@ -21,6 +21,7 @@ public final class FakeBlock { private final List chunks; private final Quaternionf blockRotation; private final TransportSession session; + private final List despawnCallbacks = new ArrayList<>(2); private boolean despawned = false; public FakeBlock(Location origin, BlockKey blockKey, int gridN, List chunks, @@ -53,10 +54,39 @@ public FakeBlock(Location origin, BlockKey blockKey, int gridN, List c */ public Quaternionf blockRotation() { return new Quaternionf(blockRotation); } + /** + * Runs {@code callback} when {@link #despawn()} executes — the hook for + * anything whose lifetime is tied to this FakeBlock (concurrency-slot + * release, bookkeeping). Runs immediately if already despawned. Callbacks + * fire exactly once, after the session closes, each guarded so one + * failure can't starve the rest. Main thread only. + */ + public void onDespawn(Runnable callback) { + if (despawned) { + callback.run(); + return; + } + despawnCallbacks.add(callback); + } + /** Removes every spawned display handle. Idempotent. Must be called on the main thread. */ public void despawn() { if (despawned) return; despawned = true; - session.close(); + try { + session.close(); + } finally { + // Callbacks run even when the transport teardown throws — + // whatever is keyed to this FakeBlock's lifetime (concurrency + // slot, registries) must not leak on a failed close. + for (Runnable r : despawnCallbacks) { + try { + r.run(); + } catch (RuntimeException ignored) { + // A misbehaving callback must not block the others. + } + } + despawnCallbacks.clear(); + } } } diff --git a/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakListener.java b/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakListener.java index 2297731..b45e96f 100644 --- a/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakListener.java +++ b/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakListener.java @@ -4,6 +4,7 @@ import org.inventivetalent.tessera.core.BakeKey; import org.inventivetalent.tessera.core.BlockKey; import org.inventivetalent.tessera.core.FakeBlock; +import org.inventivetalent.tessera.api.effect.ShatterContext; import org.inventivetalent.tessera.core.VariantKey; import org.inventivetalent.tessera.effect.EffectContext; import org.inventivetalent.tessera.effect.builtin.DirectionalShrinkEffect; @@ -129,19 +130,27 @@ private void spawn(Player viewer, BakeKey bakeKey, BlockData blockData, String matchedKey = VariantKey.pickMatching(fullStateKey, registry.variantsFor(key).keySet()); Quaternionf blockRotation = registry.rotationFor(key, matchedKey); + boolean fillInterior = cfg.fillInterior() + || plugin.breakEffectProviders().wantsInteriorFill(); FakeBlock fb; try { - fb = factory.create(viewer, breakLoc, bakeKey, blockRotation, cfg.fillInterior(), eyeDir); + fb = factory.create(viewer, breakLoc, bakeKey, blockRotation, fillInterior, eyeDir); } catch (RuntimeException re) { active.decrementAndGet(); plugin.getLogger().warning("Failed to spawn FakeBlock for " + bakeKey + ": " + re.getMessage()); return; } + // The concurrency slot is held for exactly the FakeBlock's lifetime: + // whoever despawns it (the effect's scheduled cleanup, a break-effect + // provider, plugin shutdown, ...) releases the slot, so effects of any + // duration can't wedge the cap or release it early. + fb.onDespawn(active::decrementAndGet); + + ShatterContext shatterContext = new ShatterContext( + viewer, eyeDir, blockData, ShatterContext.Mode.POST_BREAK); + if (plugin.breakEffectProviders().dispatch(fb, shatterContext)) return; EffectContext ctx = new EffectContext(eyeDir, System.currentTimeMillis(), cfg.effectDurationMs(), plugin); new DirectionalShrinkEffect(cfg.collapseStyle()).applyTimed(fb, ctx); - - long despawnTicks = (cfg.effectDurationMs() / 50L) + 5L; - plugin.getServer().getScheduler().runTaskLater(plugin, active::decrementAndGet, despawnTicks); } } diff --git a/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakProgressListener.java b/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakProgressListener.java index 6233138..55bb69f 100644 --- a/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakProgressListener.java +++ b/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakProgressListener.java @@ -1,5 +1,6 @@ package org.inventivetalent.tessera.plugin; +import org.inventivetalent.tessera.api.effect.ShatterContext; import org.inventivetalent.tessera.assemble.BlockGeometry; import org.inventivetalent.tessera.assemble.FakeBlockFactory; import org.inventivetalent.tessera.core.*; @@ -267,7 +268,7 @@ public void onInteract(PlayerInteractEvent event) { FakeBlockFactory.PreloadPlan plan; try { plan = factory.completePlan(breakLoc, preload.bakeKey(), - preload.blockRotation(), cfg.fillInterior(), eyeDir, + preload.blockRotation(), effectiveFillInterior(cfg), eyeDir, preload.prespawnedChunks(), preload.session()); } catch (RuntimeException re) { preload.session().close(); @@ -279,6 +280,7 @@ public void onInteract(PlayerInteractEvent event) { Math.floor(breakLoc.getX()), Math.floor(breakLoc.getY()), Math.floor(breakLoc.getZ())); FakeBlock fb = new FakeBlock(origin, preload.bakeKey().block(), gridN, new ArrayList<>(plan.frontRefs().values()), preload.blockRotation(), plan.session()); + fb.onDespawn(active::decrementAndGet); TrackedBreak tb = buildTrackedBreak(player.getUniqueId(), preload.bakeKey().block(), fb, block.getBlockData(), eyeDir, plan); tracker.put(posKey, tb); @@ -366,6 +368,16 @@ private static boolean tooFast(Block block, Player player, TesseraConfig cfg) { return min > 0 && estimateBreakDurationMs(block, player) < min; } + /** + * Interior fill applies when the server config asks for it or a + * registered break-effect provider requests it (a provider that animates + * the whole block wants a solid lattice even on servers that keep the + * built-in effect hollow). + */ + private boolean effectiveFillInterior(TesseraConfig cfg) { + return cfg.fillInterior() || plugin.breakEffectProviders().wantsInteriorFill(); + } + @EventHandler(priority = EventPriority.MONITOR) public void onQuit(PlayerQuitEvent event) { UUID id = event.getPlayer().getUniqueId(); @@ -413,7 +425,7 @@ private void spawnAndRegister(Player player, Block block, Location breakLoc, FakeBlockFactory.PreloadPlan plan; try { plan = factory.preloadAndPending(player, breakLoc, bakeKey, blockRotation, - cfg.fillInterior(), eyeDir); + effectiveFillInterior(cfg), eyeDir); } catch (RuntimeException re) { active.decrementAndGet(); plugin.getLogger().warning("Failed to spawn FakeBlock for " + bakeKey + ": " + re.getMessage()); @@ -425,6 +437,7 @@ private void spawnAndRegister(Player player, Block block, Location breakLoc, Math.floor(breakLoc.getX()), Math.floor(breakLoc.getY()), Math.floor(breakLoc.getZ())); FakeBlock fb = new FakeBlock(origin, key, gridN, new ArrayList<>(plan.frontRefs().values()), blockRotation, plan.session()); + fb.onDespawn(active::decrementAndGet); TrackedBreak tb = buildTrackedBreak(player.getUniqueId(), key, fb, blockData, eyeDir, plan); tracker.put(posKey, tb); @@ -629,6 +642,8 @@ void onRealBreak(BlockPosKey posKey) { TrackedBreak tb = tracker.remove(posKey); if (tb == null) return; cancelReverseTask(tb); + cancelSmoothTask(tb); + cancelPendingSpawnTask(tb); if (tb.barrierSent) { Player p = Bukkit.getPlayer(tb.currentPlayerId); if (p != null) { @@ -641,12 +656,92 @@ void onRealBreak(BlockPosKey posKey) { } tb.barrierSent = false; } - disposeImmediate(tb, /*restoreBlock=*/ false); clearPreloadsAt(posKey); + if (handOffToProvider(tb)) { + if (plugin.tesseraConfig().debug()) plugin.getLogger().info( + "[" + ts() + "] [debug-progress] real-break-handoff " + tb.key + " at " + posKey + + " chunks=" + tb.fakeBlock.chunks().size()); + return; + } + disposeImmediate(tb, /*restoreBlock=*/ false); if (plugin.tesseraConfig().debug()) plugin.getLogger().info( "[" + ts() + "] [debug-progress] real-break " + tb.key + " at " + posKey); } + /** + * Offer the surviving lattice to a registered break-effect provider + * instead of disposing it. The wave consumed the near-side chunks; what + * the provider receives is the material that was still un-mined at the + * moment the block actually broke: + *

    + *
  1. all still-pending chunks (back faces + interior) are spawned in + * one batch, at the shell factor matching the current lattice;
  2. + *
  3. the spawn-time shell compression is undone if it hadn't been — + * the real block is gone at this instant, so there is nothing to + * z-fight with. Rescaling reads the actual per-chunk transform + * scales (not the wave-tracking arrays, whose freshly-spawned + * interior slots hold a sentinel);
  4. + *
  5. chunks the wave shrank to invisibility but hadn't culled yet are + * despawned, so mined-away material stays gone.
  6. + *
+ * Returns false (leaving teardown to the caller) when no provider is + * registered or the provider declines. + */ + private boolean handOffToProvider(TrackedBreak tb) { + BreakEffectProviders providers = plugin.breakEffectProviders(); + if (!providers.hasProvider() || tb.fakeBlock.despawned()) return false; + + materializeRemainder(tb); + + if (!tb.shellExpanded) { + float[] actual = DirectionalShrinkEffect.captureBaseScales(tb.fakeBlock); + DirectionalShrinkEffect.rescaleShell(tb.fakeBlock, actual, actual.clone(), + 1f / FakeBlockFactory.INITIAL_SHELL_COMPRESSION, 0, null); + tb.shellExpanded = true; + } + + float minScale = (float) plugin.tesseraConfig().progressMinDelta(); + for (ChunkRef ref : tb.fakeBlock.chunks()) { + if (ref.handle().isAlive() + && ref.handle().getTransformation().getScale().x() < minScale) { + ref.handle().despawn(); + } + } + + ShatterContext context = new ShatterContext( + Bukkit.getPlayer(tb.currentPlayerId), tb.eyeDir, + tb.originalBlockData, ShatterContext.Mode.PROGRESS); + return providers.dispatch(tb.fakeBlock, context); + } + + /** + * Spawn every remaining pending chunk of {@code tb} in one batch, + * appending to the FakeBlock's chunk list. Sized for the worst case of + * gridN³ chunks; at the default gridN=4 that's at most 64. The + * wave-tracking arrays are deliberately not updated — after a handoff the + * progress machinery never touches this tracker again. + */ + private void materializeRemainder(TrackedBreak tb) { + List pending = tb.pendingChunks; + if (pending == null || pending.isEmpty()) return; + tb.pendingChunks = new ArrayList<>(); + + float shellFactor = tb.shellExpanded ? 1.0f : FakeBlockFactory.INITIAL_SHELL_COMPRESSION; + FakeBlockFactory.PendingChunkSpec donor = null; + for (FakeBlockFactory.PendingChunkSpec s : pending) { + if (s.interior()) { donor = s; break; } + } + FakeBlockFactory.PendingSpawnContext ctx = factory.beginPendingBatch(tb.fakeBlock, donor); + for (FakeBlockFactory.PendingChunkSpec spec : pending) { + try { + tb.fakeBlock.chunks().add(factory.spawnPendingChunk(ctx, spec, shellFactor)); + } catch (RuntimeException re) { + plugin.getLogger().warning( + "handoff spawn failed for " + spec.coord() + ": " + re.getMessage()); + } + } + } + public boolean isTracked(BlockPosKey posKey) { return tracker.get(posKey) != null; } @@ -700,7 +795,10 @@ private void disposeImmediate(TrackedBreak tb, boolean restoreBlock) { } catch (RuntimeException re) { plugin.getLogger().warning("despawn FakeBlock failed: " + re.getMessage()); } - active.decrementAndGet(); + // The concurrency slot is released by the FakeBlock's onDespawn + // callback (registered at spawn), not here — despawn() is idempotent + // and the callback fires exactly once regardless of which teardown + // path got there first. } private static void cancelReverseTask(TrackedBreak tb) { diff --git a/src/main/java/org/inventivetalent/tessera/plugin/BreakEffectProviders.java b/src/main/java/org/inventivetalent/tessera/plugin/BreakEffectProviders.java new file mode 100644 index 0000000..4662cc7 --- /dev/null +++ b/src/main/java/org/inventivetalent/tessera/plugin/BreakEffectProviders.java @@ -0,0 +1,161 @@ +package org.inventivetalent.tessera.plugin; + +import org.inventivetalent.tessera.api.effect.BreakEffectProvider; +import org.inventivetalent.tessera.api.effect.ShatterContext; +import org.inventivetalent.tessera.core.FakeBlock; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginDisableEvent; +import org.bukkit.plugin.Plugin; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; +import java.util.function.LongSupplier; +import java.util.logging.Logger; + +/** + * Single-slot registry and dispatcher for {@link BreakEffectProvider}s. + * + *

Both break listeners route through {@link #dispatch} at the moment they + * would run the built-in collapse effect; a registered provider gets first + * refusal. Claimed FakeBlocks are tracked so they can be force-despawned when + * the provider unregisters (or its plugin disables) mid-effect, and each claim + * schedules a safety despawn at {@code limits.providerMaxEffectDurationMs} so + * a provider that forgets its despawn obligation can't hold concurrency slots + * forever — {@code FakeBlock.despawn()} is idempotent, so the backstop is a + * no-op for well-behaved providers. + * + *

Main-thread only, like everything else in the break pipeline. + */ +public final class BreakEffectProviders implements Listener { + + /** Schedules the safety-despawn backstop; injected so tests run scheduler-free. */ + @FunctionalInterface + interface DelayedRunner { + void runLater(Runnable task, long delayTicks); + } + + private record Registration(Plugin owner, BreakEffectProvider provider) {} + + private final Logger logger; + private final LongSupplier maxEffectDurationMs; + private final DelayedRunner scheduler; + + private Registration current; + /** True after the current provider's first thrown exception was logged. */ + private boolean throwLogged; + private final Set outstanding = + Collections.newSetFromMap(new IdentityHashMap<>()); + + BreakEffectProviders(Logger logger, LongSupplier maxEffectDurationMs, DelayedRunner scheduler) { + this.logger = logger; + this.maxEffectDurationMs = maxEffectDurationMs; + this.scheduler = scheduler; + } + + /** First-wins single slot; re-registering the same provider instance is a no-op success. */ + public boolean register(Plugin owner, BreakEffectProvider provider) { + if (current != null) { + if (current.provider() == provider) return true; + logger.warning("Break-effect provider from " + owner.getName() + + " rejected: " + current.owner().getName() + + " already registered one. Only a single provider can drive break effects."); + return false; + } + current = new Registration(owner, provider); + throwLogged = false; + logger.info("Break-effect provider registered by " + owner.getName()); + return true; + } + + /** Removes {@code provider} if registered; force-despawns any breaks it still owns. */ + public boolean unregister(BreakEffectProvider provider) { + if (current == null || current.provider() != provider) return false; + logger.info("Break-effect provider from " + current.owner().getName() + " unregistered"); + current = null; + despawnOutstanding(); + return true; + } + + /** + * True while a provider is registered. Callers with expensive handoff prep + * (materializing pending chunks, decompressing the shell) check this first + * so the work only happens when someone can claim it. + */ + public boolean hasProvider() { + return current != null; + } + + /** True when the registered provider requests interior fill for spawned lattices. */ + public boolean wantsInteriorFill() { + Registration reg = current; + if (reg == null) return false; + try { + return reg.provider().wantsInteriorFill(); + } catch (Throwable t) { + logThrow(reg, t); + return false; + } + } + + /** + * Offer {@code fakeBlock} to the registered provider. Returns true iff the + * provider claimed it — the caller must then skip the built-in effect. On + * false (no provider, decline, or provider threw) the caller runs the + * built-in effect exactly as before. + */ + public boolean dispatch(FakeBlock fakeBlock, ShatterContext context) { + Registration reg = current; + if (reg == null || fakeBlock.despawned()) return false; + + boolean claimed; + try { + claimed = reg.provider().onBlockShatter(new ShatterHandleImpl(fakeBlock), context); + } catch (Throwable t) { + logThrow(reg, t); + return false; + } + if (!claimed) return false; + + outstanding.add(fakeBlock); + fakeBlock.onDespawn(() -> outstanding.remove(fakeBlock)); + scheduler.runLater(fakeBlock::despawn, maxEffectDurationMs.getAsLong() / 50L + 5L); + return true; + } + + @EventHandler + public void onPluginDisable(PluginDisableEvent event) { + if (current != null && current.owner() == event.getPlugin()) { + unregister(current.provider()); + } + } + + /** Plugin shutdown: drop the registration and despawn everything still owned. */ + public void shutdown() { + current = null; + despawnOutstanding(); + } + + private void despawnOutstanding() { + // Despawn callbacks mutate `outstanding`; iterate a copy. + for (FakeBlock fb : new ArrayList<>(outstanding)) { + try { + fb.despawn(); + } catch (RuntimeException re) { + logger.warning("despawn of provider-owned FakeBlock failed: " + re.getMessage()); + } + } + outstanding.clear(); + } + + private void logThrow(Registration reg, Throwable t) { + if (throwLogged) return; + throwLogged = true; + logger.warning("Break-effect provider from " + reg.owner().getName() + + " threw " + t.getClass().getSimpleName() + " (" + t.getMessage() + + "); treating as decline. Further exceptions from this provider" + + " are suppressed from the log."); + } +} diff --git a/src/main/java/org/inventivetalent/tessera/plugin/ShatterChunkImpl.java b/src/main/java/org/inventivetalent/tessera/plugin/ShatterChunkImpl.java new file mode 100644 index 0000000..b4a38b0 --- /dev/null +++ b/src/main/java/org/inventivetalent/tessera/plugin/ShatterChunkImpl.java @@ -0,0 +1,102 @@ +package org.inventivetalent.tessera.plugin; + +import org.inventivetalent.tessera.api.effect.ShatterChunk; +import org.inventivetalent.tessera.assemble.BlockGeometry; +import org.inventivetalent.tessera.core.ChunkCoord; +import org.inventivetalent.tessera.core.ChunkRef; +import org.inventivetalent.tessera.core.FaceDir; +import org.bukkit.util.Transformation; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import java.util.Collections; +import java.util.Set; + +/** + * {@link ShatterChunk} adapter over a {@link ChunkRef}. The canonical face + * rotation is captured from the chunk's spawn transformation (it is the same + * {@code Ry(180°)} for every chunk of every FakeBlock — see the invariant on + * {@code FakeBlockFactory}) so {@link #setPose} can rebuild transformations + * without reaching into the assemble layer's rotation tables. + */ +final class ShatterChunkImpl implements ShatterChunk { + + private final ChunkRef ref; + private final Quaternionf blockRotation; + private final Quaternionf canonicalRotation; + private final Vector3f initialCenter; + private final Set outwardFaces; + + ShatterChunkImpl(ChunkRef ref, Quaternionf blockRotation) { + this.ref = ref; + this.blockRotation = new Quaternionf(blockRotation); + this.canonicalRotation = new Quaternionf( + ref.handle().getTransformation().getRightRotation()); + Vector3f blockCenter = new Vector3f(0.5f, 0.5f, 0.5f); + this.initialCenter = new Vector3f(blockCenter) + .add(new Quaternionf(blockRotation) + .transform(ref.localCenter().sub(blockCenter))); + this.outwardFaces = Collections.unmodifiableSet(ref.outwardFaces()); + } + + @Override + public ChunkCoord coord() { + return ref.coord(); + } + + @Override + public Vector3f localCenter() { + return ref.localCenter(); + } + + @Override + public Set outwardFaces() { + return outwardFaces; + } + + @Override + public boolean interior() { + return outwardFaces.isEmpty(); + } + + @Override + public Vector3f initialCenter() { + return new Vector3f(initialCenter); + } + + @Override + public float currentScale() { + return ref.handle().getTransformation().getScale().x(); + } + + @Override + public boolean isAlive() { + return ref.handle().isAlive(); + } + + @Override + public void setPose(Vector3f centerOffset, Quaternionf tumble, float scale, + int delayTicks, int durationTicks) { + Quaternionf left = new Quaternionf(tumble).mul(blockRotation); + Vector3f translation = BlockGeometry.poseTranslation( + centerOffset, left, canonicalRotation, scale); + ref.handle().setTransformation(new Transformation( + translation, left, new Vector3f(scale, scale, scale), + new Quaternionf(canonicalRotation)), delayTicks, durationTicks); + } + + @Override + public Transformation getTransformation() { + return ref.handle().getTransformation(); + } + + @Override + public void setTransformation(Transformation transformation, int delayTicks, int durationTicks) { + ref.handle().setTransformation(transformation, delayTicks, durationTicks); + } + + @Override + public void despawn() { + ref.handle().despawn(); + } +} diff --git a/src/main/java/org/inventivetalent/tessera/plugin/ShatterHandleImpl.java b/src/main/java/org/inventivetalent/tessera/plugin/ShatterHandleImpl.java new file mode 100644 index 0000000..e510e7f --- /dev/null +++ b/src/main/java/org/inventivetalent/tessera/plugin/ShatterHandleImpl.java @@ -0,0 +1,71 @@ +package org.inventivetalent.tessera.plugin; + +import org.inventivetalent.tessera.api.effect.ShatterChunk; +import org.inventivetalent.tessera.api.effect.ShatterHandle; +import org.inventivetalent.tessera.core.BlockKey; +import org.inventivetalent.tessera.core.ChunkRef; +import org.inventivetalent.tessera.core.FakeBlock; +import org.bukkit.Location; +import org.joml.Quaternionf; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * {@link ShatterHandle} adapter over a {@link FakeBlock}. The chunk list is + * snapshotted at construction (handoff time) from the alive refs only, so a + * provider never sees fragments the progress wave already consumed. + */ +final class ShatterHandleImpl implements ShatterHandle { + + private final FakeBlock fakeBlock; + private final List chunks; + + ShatterHandleImpl(FakeBlock fakeBlock) { + this.fakeBlock = fakeBlock; + Quaternionf blockRotation = fakeBlock.blockRotation(); + List alive = new ArrayList<>(fakeBlock.chunks().size()); + for (ChunkRef ref : fakeBlock.chunks()) { + if (ref.handle().isAlive()) { + alive.add(new ShatterChunkImpl(ref, blockRotation)); + } + } + this.chunks = Collections.unmodifiableList(alive); + } + + @Override + public Location origin() { + return fakeBlock.origin(); + } + + @Override + public BlockKey blockKey() { + return fakeBlock.blockKey(); + } + + @Override + public int gridN() { + return fakeBlock.gridN(); + } + + @Override + public Quaternionf blockRotation() { + return fakeBlock.blockRotation(); + } + + @Override + public List chunks() { + return chunks; + } + + @Override + public boolean despawned() { + return fakeBlock.despawned(); + } + + @Override + public void despawn() { + fakeBlock.despawn(); + } +} diff --git a/src/main/java/org/inventivetalent/tessera/plugin/TesseraApiImpl.java b/src/main/java/org/inventivetalent/tessera/plugin/TesseraApiImpl.java index 6f19da6..bf3a45b 100644 --- a/src/main/java/org/inventivetalent/tessera/plugin/TesseraApiImpl.java +++ b/src/main/java/org/inventivetalent/tessera/plugin/TesseraApiImpl.java @@ -1,10 +1,12 @@ package org.inventivetalent.tessera.plugin; import org.bukkit.block.data.BlockData; +import org.bukkit.plugin.Plugin; import org.inventivetalent.tessera.api.BakeOutcome; import org.inventivetalent.tessera.api.ChunkLayout; import org.inventivetalent.tessera.api.SkinPayload; import org.inventivetalent.tessera.api.TesseraApi; +import org.inventivetalent.tessera.api.effect.BreakEffectProvider; import org.inventivetalent.tessera.assemble.FakeBlockFactory; import org.inventivetalent.tessera.core.BakeKey; import org.inventivetalent.tessera.core.BlockKey; @@ -108,4 +110,14 @@ public boolean canBakeNewBlocks() { TesseraConfig cfg = plugin.tesseraConfig(); return cfg.hasLicense() || !cfg.mineskinApiKey().isBlank(); } + + @Override + public boolean registerBreakEffectProvider(Plugin owner, BreakEffectProvider provider) { + return plugin.breakEffectProviders().register(owner, provider); + } + + @Override + public boolean unregisterBreakEffectProvider(BreakEffectProvider provider) { + return plugin.breakEffectProviders().unregister(provider); + } } diff --git a/src/main/java/org/inventivetalent/tessera/plugin/TesseraCommand.java b/src/main/java/org/inventivetalent/tessera/plugin/TesseraCommand.java index bcd7b4c..5676ffa 100644 --- a/src/main/java/org/inventivetalent/tessera/plugin/TesseraCommand.java +++ b/src/main/java/org/inventivetalent/tessera/plugin/TesseraCommand.java @@ -44,7 +44,10 @@ * v1 command surface for testing the splitting/effect pipeline. Subcommands: * *

- *   /tessera test [material] [static]   bake (if needed) + spawn FakeBlock; "static" = no shrink
+ *   /tessera test [material] [static|builtin]  bake (if needed) + spawn FakeBlock; "static" = no
+ *                                       effect (linger for inspection), "builtin" = force the
+ *                                       built-in shrink even when a break-effect provider is
+ *                                       registered
  *   /tessera bake <material> [tint:#RRGGBB]
  *                                       bake without spawning; reports upload count + completion.
  *                                       Tint is required for biome-tinted blocks (grass, leaves,
@@ -130,7 +133,7 @@ public final class TesseraCommand implements CommandExecutor, TabCompleter {
     private static final List PERM_KINDS = List.of("all", "head", "source", "tile");
     private static final List CENTER_HINTS = List.of("-0.5", "0", "0.5", "reset");
     private static final List FLOAT_HINTS = List.of("-90", "0", "90", "180", "270");
-    private static final List STATIC_FLAG = List.of("static");
+    private static final List TEST_FLAGS = List.of("static", "builtin");
     private static final List TINT_HINT = List.of("tint:#");
 
     private static final List HEAD_FACES = lower(HeadFace.values());
@@ -207,19 +210,21 @@ private boolean handleTest(CommandSender sender, String[] args) {
             sender.sendMessage("§cUnknown material: " + args[1]);
             return true;
         }
-        // Optional trailing "static" flag → spawn without the shrink effect.
-        // Useful when debugging texture / rotation - the FakeBlock lingers
-        // for STATIC_LIFETIME_TICKS so you can rotate around and inspect.
+        // Optional trailing flags:
+        //   "static"  → spawn without any effect and linger for inspection.
+        //   "builtin" → force the built-in shrink effect even when an
+        //               external break-effect provider is registered.
         boolean staticMode = false;
+        boolean builtinMode = false;
         for (int i = 2; i < args.length; i++) {
-            if (args[i].equalsIgnoreCase("static")) {
-                staticMode = true;
-                break;
-            }
+            if (args[i].equalsIgnoreCase("static")) staticMode = true;
+            if (args[i].equalsIgnoreCase("builtin")) builtinMode = true;
         }
         BlockKey key = blockKeyOf(mat);
         Location target = pickTargetLocation(p);
         boolean st = staticMode;
+        boolean bi = builtinMode;
+        org.bukkit.block.data.BlockData testData = mat.createBlockData();
 
         if (!registry.has(key)) {
             if (plugin.tesseraConfig().mineskinApiKey().isBlank()) {
@@ -238,24 +243,30 @@ private boolean handleTest(CommandSender sender, String[] args) {
                     return;
                 }
                 sender.sendMessage("§aBake complete; spawning" + (st ? " (static)" : "") + ".");
-                spawnTest(sender, p, key, target, st);
+                spawnTest(sender, p, key, testData, target, st, bi);
             }));
             return true;
         }
 
-        spawnTest(sender, p, key, target, st);
+        spawnTest(sender, p, key, testData, target, st, bi);
         return true;
     }
 
     /**
      * If {@code staticMode}, spawn and let it linger for ~30s without running
      * any effect — useful when debugging texture/rotation issues without
-     * being rushed by the shrink animation.
+     * being rushed by the shrink animation. Otherwise the spawn goes through
+     * the same effect selection as a real break — a registered break-effect
+     * provider gets first refusal unless {@code forceBuiltin}.
      */
-    private void spawnTest(CommandSender sender, Player p, BlockKey key, Location target, boolean staticMode) {
+    private void spawnTest(CommandSender sender, Player p, BlockKey key,
+                           org.bukkit.block.data.BlockData blockData, Location target,
+                           boolean staticMode, boolean forceBuiltin) {
         org.bukkit.util.Vector eyeDir = p.getEyeLocation().getDirection();
+        boolean fillInterior = plugin.tesseraConfig().fillInterior()
+                || (!staticMode && !forceBuiltin && plugin.breakEffectProviders().wantsInteriorFill());
         FakeBlock fb = factory.create(p, target, key, new org.joml.Quaternionf(),
-                plugin.tesseraConfig().fillInterior(), eyeDir);
+                fillInterior, eyeDir);
         if (fb.chunks().isEmpty()) {
             sender.sendMessage("§cFakeBlock has 0 chunks - heads.json entry for " + key + " is empty.");
             return;
@@ -271,6 +282,14 @@ private void spawnTest(CommandSender sender, Player p, BlockKey key, Location ta
             Bukkit.getScheduler().runTaskLater(plugin, fb::despawn, 5L * 60L * 20L);
             return;
         }
+        if (!forceBuiltin && plugin.breakEffectProviders().dispatch(fb,
+                new org.inventivetalent.tessera.api.effect.ShatterContext(
+                        p, eyeDir, blockData,
+                        org.inventivetalent.tessera.api.effect.ShatterContext.Mode.POST_BREAK))) {
+            sender.sendMessage("§aSpawned FakeBlock for " + key + " at " + formatLoc(target)
+                    + " §7(external break-effect provider)");
+            return;
+        }
         EffectContext ctx = new EffectContext(
                 p.getEyeLocation().getDirection(),
                 System.currentTimeMillis(),
@@ -1261,7 +1280,7 @@ public List onTabComplete(@NotNull CommandSender sender, @NotNull Comman
         return switch (sub) {
             case "test" -> {
                 if (args.length == 2) yield matchMaterials(args[1]);
-                if (args.length == 3) yield match(args[2], STATIC_FLAG);
+                if (args.length == 3) yield match(args[2], TEST_FLAGS);
                 yield Collections.emptyList();
             }
             case "bake" -> {
diff --git a/src/main/java/org/inventivetalent/tessera/plugin/TesseraConfig.java b/src/main/java/org/inventivetalent/tessera/plugin/TesseraConfig.java
index 14f882f..c3fa3fb 100644
--- a/src/main/java/org/inventivetalent/tessera/plugin/TesseraConfig.java
+++ b/src/main/java/org/inventivetalent/tessera/plugin/TesseraConfig.java
@@ -25,6 +25,7 @@ public record TesseraConfig(
         Set enabledWorlds,
         Set disabledWorlds,
         int maxConcurrentFakeBlocks,
+        long providerMaxEffectDurationMs,
         AnimationMode animationMode,
         CollapseStyle collapseStyle,
         int effectDurationMs,
@@ -101,6 +102,9 @@ public static TesseraConfig from(FileConfiguration cfg) {
                 normalizeWorlds(readStringList(cfg, "worlds.enabled", "enabledWorlds", List.of("*"))),
                 normalizeWorlds(readStringList(cfg, "worlds.disabled", "disabledWorlds", List.of())),
                 readInt(cfg, "limits.maxConcurrentFakeBlocks", "maxConcurrentFakeBlocks", 8),
+                // Ceiling for externally provided break effects (api break-effect
+                // providers); the safety despawn fires this long after a claim.
+                cfg.getLong("limits.providerMaxEffectDurationMs", 10_000L),
                 mode,
                 style,
                 readInt(cfg, "animation.durationMs", "effectDurationMs", 600),
diff --git a/src/main/java/org/inventivetalent/tessera/plugin/TesseraPlugin.java b/src/main/java/org/inventivetalent/tessera/plugin/TesseraPlugin.java
index 38a0d76..2ee4c39 100644
--- a/src/main/java/org/inventivetalent/tessera/plugin/TesseraPlugin.java
+++ b/src/main/java/org/inventivetalent/tessera/plugin/TesseraPlugin.java
@@ -61,6 +61,7 @@ public final class TesseraPlugin extends JavaPlugin {
     private ProgressSource progressBridge;
     private BackendClient backendClient;
     private Path addonsDir;
+    private BreakEffectProviders breakEffectProviders;
 
     @Override
     public void onEnable() {
@@ -173,6 +174,16 @@ public void onEnable() {
         this.bakerExecutor = Executors.newFixedThreadPool(2, named("Tessera-Baker"));
         this.baker = new BlockBaker(getLogger(), () -> this.config.debug(), assets, mcVersion, registry, uploader, diskCache, pngDir, bakerExecutor);
 
+        // Break-effect provider slot (extension API v2): a third-party plugin
+        // may register a provider that replaces the built-in collapse effect;
+        // both break listeners consult this before running it. Registered as
+        // a Listener for PluginDisableEvent so a dying provider plugin is
+        // detached (and its claimed breaks despawned) automatically.
+        this.breakEffectProviders = new BreakEffectProviders(getLogger(),
+                () -> this.config.providerMaxEffectDurationMs(),
+                (task, delayTicks) -> getServer().getScheduler().runTaskLater(this, task, delayTicks));
+        getServer().getPluginManager().registerEvents(breakEffectProviders, this);
+
         // Public extension API: let third-party plugins read pre-baked block
         // data and request bakes (see org.inventivetalent.tessera.api). Exposed
         // via Bukkit's ServicesManager — consumers load TesseraApi.class. The
@@ -224,6 +235,7 @@ public void onEnable() {
     public void onDisable() {
         if (progressBridge != null) progressBridge.shutdown();
         progressBridge = null;
+        if (breakEffectProviders != null) breakEffectProviders.shutdown();
         if (progressListener != null) progressListener.shutdown();
         if (itemFactory != null) itemFactory.clear();
         if (uploader != null) uploader.cancelAll();
@@ -310,6 +322,9 @@ public void reloadTesseraConfig() {
 
     public HeadsRegistry registry() { return registry; }
 
+    /** Break-effect provider slot; both break listeners consult it before the built-in effect. */
+    public BreakEffectProviders breakEffectProviders() { return breakEffectProviders; }
+
     /**
      * Stable per-install identifier sent as {@code X-Tessera-Server-Id} so
      * the backend can flag a single license seen across many distinct
diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml
index c72fef7..ac862e6 100644
--- a/src/main/resources/config.yml
+++ b/src/main/resources/config.yml
@@ -89,6 +89,11 @@ limits:
   # Hard cap on concurrent FakeBlocks (sub-block effect entities).
   # Excess block-break events fall back to the vanilla animation.
   maxConcurrentFakeBlocks: 8
+  # Ceiling (ms) for break effects run by an external break-effect provider
+  # (extension API). If a provider hasn't despawned a claimed break by then,
+  # Tessera despawns it to reclaim the concurrency slot. Irrelevant unless a
+  # plugin registers a provider.
+  providerMaxEffectDurationMs: 10000
 
 animation:
   # When the chunked animation plays.
diff --git a/src/test/java/org/inventivetalent/tessera/assemble/BlockGeometryTest.java b/src/test/java/org/inventivetalent/tessera/assemble/BlockGeometryTest.java
index c70669e..21cd018 100644
--- a/src/test/java/org/inventivetalent/tessera/assemble/BlockGeometryTest.java
+++ b/src/test/java/org/inventivetalent/tessera/assemble/BlockGeometryTest.java
@@ -55,4 +55,61 @@ void rejectsZeroOrNegativeGridN() {
         assertThrows(IllegalArgumentException.class, () -> new BlockGeometry(0, new Quaternionf()));
         assertThrows(IllegalArgumentException.class, () -> new BlockGeometry(-1, new Quaternionf()));
     }
+
+    @ParameterizedTest
+    @ValueSource(ints = {1, 2, 4, 8})
+    void poseTranslationReproducesSpawnTranslation(int gridN) {
+        // translationFor derives the cube center from the grid cell and
+        // assumes L = blockRotation; poseTranslation takes the center and L
+        // explicitly. Feeding it the spawn pose must give bit-for-bit the
+        // same T, otherwise a chunk re-posed at its spawn position would
+        // visibly jump.
+        Quaternionf blockRot = new Quaternionf().rotateY((float) Math.toRadians(90));
+        Quaternionf faceRot = new Quaternionf().rotateY((float) Math.PI);
+        BlockGeometry geom = new BlockGeometry(gridN, blockRot);
+        float scale = geom.chunkScale();
+        Vector3f blockCenter = new Vector3f(0.5f, 0.5f, 0.5f);
+        for (int x = 0; x < gridN; x++)
+            for (int y = 0; y < gridN; y++)
+                for (int z = 0; z < gridN; z++) {
+                    ChunkCoord c = new ChunkCoord(x, y, z);
+                    Vector3f cell = geom.chunkLocalCenter(c).sub(blockCenter);
+                    Vector3f center = new Vector3f(blockCenter)
+                            .add(new Quaternionf(blockRot).transform(cell));
+                    Vector3f expected = geom.translationFor(c, faceRot, scale);
+                    Vector3f actual = BlockGeometry.poseTranslation(center, blockRot, faceRot, scale);
+                    assertEquals(expected.x, actual.x, EPS);
+                    assertEquals(expected.y, actual.y, EPS);
+                    assertEquals(expected.z, actual.z, EPS);
+                }
+    }
+
+    @Test
+    void poseTranslationPinsCenterUnderArbitraryRotation() {
+        // The whole point of the compensation: for any left rotation the
+        // rendered cube center (T + L*S*R*CUBE_CENTER_PRE) must land exactly
+        // on the requested center — a spinning chunk rotates in place rather
+        // than orbiting.
+        java.util.Random rng = new java.util.Random(424242);
+        Quaternionf faceRot = new Quaternionf().rotateY((float) Math.PI);
+        for (int i = 0; i < 200; i++) {
+            Quaternionf left = new Quaternionf().rotateXYZ(
+                    (float) (rng.nextDouble() * Math.PI * 2),
+                    (float) (rng.nextDouble() * Math.PI * 2),
+                    (float) (rng.nextDouble() * Math.PI * 2));
+            float scale = 0.05f + rng.nextFloat() * 2f;
+            Vector3f center = new Vector3f(
+                    rng.nextFloat() * 8f - 4f,
+                    rng.nextFloat() * 8f - 4f,
+                    rng.nextFloat() * 8f - 4f);
+            Vector3f t = BlockGeometry.poseTranslation(center, left, faceRot, scale);
+            Vector3f rendered = new Quaternionf(faceRot).transform(BlockGeometry.cubeCenterPre());
+            rendered.mul(scale);
+            new Quaternionf(left).transform(rendered);
+            rendered.add(t);
+            assertEquals(center.x, rendered.x, 1e-4f);
+            assertEquals(center.y, rendered.y, 1e-4f);
+            assertEquals(center.z, rendered.z, 1e-4f);
+        }
+    }
 }
diff --git a/src/test/java/org/inventivetalent/tessera/plugin/BreakEffectProvidersTest.java b/src/test/java/org/inventivetalent/tessera/plugin/BreakEffectProvidersTest.java
new file mode 100644
index 0000000..9a413de
--- /dev/null
+++ b/src/test/java/org/inventivetalent/tessera/plugin/BreakEffectProvidersTest.java
@@ -0,0 +1,235 @@
+package org.inventivetalent.tessera.plugin;
+
+import be.seeseemelk.mockbukkit.MockBukkit;
+import org.inventivetalent.tessera.api.effect.BreakEffectProvider;
+import org.inventivetalent.tessera.api.effect.ShatterContext;
+import org.inventivetalent.tessera.api.effect.ShatterHandle;
+import org.inventivetalent.tessera.core.BlockKey;
+import org.inventivetalent.tessera.core.ChunkCoord;
+import org.inventivetalent.tessera.core.ChunkRef;
+import org.inventivetalent.tessera.core.FaceDir;
+import org.inventivetalent.tessera.core.FakeBlock;
+import org.inventivetalent.tessera.transport.DisplayHandle;
+import org.inventivetalent.tessera.transport.TransportSession;
+import org.bukkit.Location;
+import org.bukkit.event.server.PluginDisableEvent;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.util.Transformation;
+import org.bukkit.util.Vector;
+import org.joml.Quaternionf;
+import org.joml.Vector3f;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.logging.Logger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The provider slot's contract — single registration, decline/throw fallback,
+ * claim bookkeeping, and the safety-despawn backstop — exercised without a
+ * scheduler: the {@code DelayedRunner} seam records tasks so tests fire the
+ * backstop deterministically. MockBukkit supplies the {@code Plugin} handles
+ * and event plumbing only.
+ */
+class BreakEffectProvidersTest {
+
+    private static Plugin ownerA;
+    private static Plugin ownerB;
+
+    @BeforeAll
+    static void boot() {
+        MockBukkit.mock();
+        ownerA = MockBukkit.createMockPlugin("OwnerA");
+        ownerB = MockBukkit.createMockPlugin("OwnerB");
+    }
+
+    @AfterAll
+    static void shutdown() {
+        MockBukkit.unmock();
+    }
+
+    /** Recorded instead of scheduled; {@code runAll()} = the backstop firing. */
+    private static final class RecordingRunner implements BreakEffectProviders.DelayedRunner {
+        final List tasks = new ArrayList<>();
+        final List delays = new ArrayList<>();
+
+        @Override
+        public void runLater(Runnable task, long delayTicks) {
+            tasks.add(task);
+            delays.add(delayTicks);
+        }
+
+        void runAll() {
+            tasks.forEach(Runnable::run);
+        }
+    }
+
+    private static final class FakeHandle implements DisplayHandle {
+        boolean alive = true;
+        Transformation tx = new Transformation(
+                new Vector3f(), new Quaternionf(),
+                new Vector3f(0.5f, 0.5f, 0.5f),
+                new Quaternionf().rotateY((float) Math.PI));
+
+        @Override public boolean isAlive() { return alive; }
+        @Override public Transformation getTransformation() { return tx; }
+        @Override public void setTransformation(Transformation t, int delay, int duration) { tx = t; }
+        @Override public void despawn() { alive = false; }
+    }
+
+    private RecordingRunner runner;
+    private BreakEffectProviders providers;
+
+    @BeforeEach
+    void setUp() {
+        runner = new RecordingRunner();
+        providers = new BreakEffectProviders(Logger.getLogger("test"), () -> 10_000L, runner);
+    }
+
+    private static FakeBlock fakeBlock(FakeHandle... handles) {
+        List refs = new ArrayList<>();
+        int i = 0;
+        for (FakeHandle h : handles) {
+            refs.add(new ChunkRef(h, new ChunkCoord(i++, 0, 0),
+                    new Vector3f(0.125f, 0.125f, 0.125f), EnumSet.of(FaceDir.DOWN)));
+        }
+        TransportSession session = new TransportSession() {
+            @Override public DisplayHandle spawn(Location o, ItemStack s, Transformation t, float v) {
+                throw new UnsupportedOperationException();
+            }
+            @Override public void close() {
+                for (FakeHandle h : handles) h.alive = false;
+            }
+        };
+        return new FakeBlock(new Location(null, 0, 64, 0), BlockKey.of("minecraft:stone"),
+                4, refs, new Quaternionf(), session);
+    }
+
+    private static ShatterContext context() {
+        return new ShatterContext(null, new Vector(0, 0, 1), null,
+                ShatterContext.Mode.POST_BREAK);
+    }
+
+    @Test
+    void slotIsFirstWins() {
+        BreakEffectProvider first = (h, c) -> true;
+        BreakEffectProvider second = (h, c) -> true;
+        assertTrue(providers.register(ownerA, first));
+        assertFalse(providers.register(ownerB, second));
+        // Same instance re-registering is a harmless success, not a conflict.
+        assertTrue(providers.register(ownerA, first));
+        assertFalse(providers.unregister(second));
+        assertTrue(providers.unregister(first));
+        // Slot is free again after unregister.
+        assertTrue(providers.register(ownerB, second));
+    }
+
+    @Test
+    void dispatchWithoutProviderDeclines() {
+        FakeBlock fb = fakeBlock(new FakeHandle());
+        assertFalse(providers.dispatch(fb, context()));
+        assertFalse(fb.despawned());
+        assertTrue(runner.tasks.isEmpty());
+    }
+
+    @Test
+    void decliningProviderFallsThrough() {
+        providers.register(ownerA, (h, c) -> false);
+        FakeBlock fb = fakeBlock(new FakeHandle());
+        assertFalse(providers.dispatch(fb, context()));
+        assertFalse(fb.despawned());
+        assertTrue(runner.tasks.isEmpty());
+    }
+
+    @Test
+    void throwingProviderCountsAsDecline() {
+        providers.register(ownerA, (h, c) -> { throw new IllegalStateException("boom"); });
+        FakeBlock fb = fakeBlock(new FakeHandle());
+        assertFalse(providers.dispatch(fb, context()));
+        assertFalse(fb.despawned());
+        assertTrue(runner.tasks.isEmpty());
+    }
+
+    @Test
+    void handleExposesAliveChunksOnly() {
+        FakeHandle dead = new FakeHandle();
+        dead.alive = false;
+        FakeHandle live = new FakeHandle();
+        List seen = new ArrayList<>();
+        providers.register(ownerA, (h, c) -> { seen.add(h); return true; });
+
+        assertTrue(providers.dispatch(fakeBlock(dead, live), context()));
+        assertEquals(1, seen.getFirst().chunks().size());
+    }
+
+    @Test
+    void claimSchedulesSafetyDespawn() {
+        providers.register(ownerA, (h, c) -> true);
+        FakeBlock fb = fakeBlock(new FakeHandle());
+
+        assertTrue(providers.dispatch(fb, context()));
+        assertEquals(1, runner.tasks.size());
+        assertEquals(10_000L / 50L + 5L, runner.delays.getFirst());
+
+        runner.runAll();
+        assertTrue(fb.despawned());
+    }
+
+    @Test
+    void safetyDespawnIsNoopAfterProviderDespawns() {
+        List handles = new ArrayList<>();
+        providers.register(ownerA, (h, c) -> { handles.add(h); return true; });
+        FakeBlock fb = fakeBlock(new FakeHandle());
+
+        assertTrue(providers.dispatch(fb, context()));
+        handles.getFirst().despawn();
+        assertTrue(fb.despawned());
+        runner.runAll(); // idempotent — must not throw
+        assertTrue(fb.despawned());
+    }
+
+    @Test
+    void unregisterDespawnsOutstandingClaims() {
+        BreakEffectProvider provider = (h, c) -> true;
+        providers.register(ownerA, provider);
+        FakeBlock fb = fakeBlock(new FakeHandle());
+        assertTrue(providers.dispatch(fb, context()));
+
+        assertTrue(providers.unregister(provider));
+        assertTrue(fb.despawned());
+    }
+
+    @Test
+    void ownerDisableDetachesProviderAndDespawns() {
+        providers.register(ownerA, (h, c) -> true);
+        FakeBlock fb = fakeBlock(new FakeHandle());
+        assertTrue(providers.dispatch(fb, context()));
+
+        providers.onPluginDisable(new PluginDisableEvent(ownerA));
+        assertTrue(fb.despawned());
+        // Slot is free again.
+        assertTrue(providers.register(ownerB, (h, c) -> true));
+    }
+
+    @Test
+    void interiorFillFollowsProviderPreference() {
+        assertFalse(providers.wantsInteriorFill());
+        BreakEffectProvider wants = new BreakEffectProvider() {
+            @Override public boolean onBlockShatter(ShatterHandle h, ShatterContext c) { return true; }
+            @Override public boolean wantsInteriorFill() { return true; }
+        };
+        providers.register(ownerA, wants);
+        assertTrue(providers.wantsInteriorFill());
+        providers.unregister(wants);
+        assertFalse(providers.wantsInteriorFill());
+    }
+}
diff --git a/src/test/java/org/inventivetalent/tessera/plugin/TesseraConfigTest.java b/src/test/java/org/inventivetalent/tessera/plugin/TesseraConfigTest.java
index 9b060b1..d2c19e6 100644
--- a/src/test/java/org/inventivetalent/tessera/plugin/TesseraConfigTest.java
+++ b/src/test/java/org/inventivetalent/tessera/plugin/TesseraConfigTest.java
@@ -50,6 +50,7 @@ void emptyConfigYieldsAllDefaults() {
         TesseraConfig cfg = parse("");
         assertEquals(4, cfg.chunkGridSize());
         assertEquals(8, cfg.maxConcurrentFakeBlocks());
+        assertEquals(10_000L, cfg.providerMaxEffectDurationMs());
         assertEquals(AnimationMode.PROGRESS, cfg.animationMode());
         assertEquals(CollapseStyle.POP, cfg.collapseStyle());
         assertEquals(600, cfg.effectDurationMs());
diff --git a/tessera-api/src/main/java/org/inventivetalent/tessera/api/TesseraApi.java b/tessera-api/src/main/java/org/inventivetalent/tessera/api/TesseraApi.java
index 78e9e8f..4efb6d0 100644
--- a/tessera-api/src/main/java/org/inventivetalent/tessera/api/TesseraApi.java
+++ b/tessera-api/src/main/java/org/inventivetalent/tessera/api/TesseraApi.java
@@ -1,6 +1,8 @@
 package org.inventivetalent.tessera.api;
 
 import org.bukkit.block.data.BlockData;
+import org.bukkit.plugin.Plugin;
+import org.inventivetalent.tessera.api.effect.BreakEffectProvider;
 import org.inventivetalent.tessera.core.BakeKey;
 import org.inventivetalent.tessera.core.BlockKey;
 import org.inventivetalent.tessera.core.ChunkCoord;
@@ -39,8 +41,15 @@
  */
 public interface TesseraApi {
 
-    /** Current API contract version. Bumped on incompatible changes. */
-    int VERSION = 1;
+    /**
+     * Current API contract version. Bumped on incompatible changes.
+     * 
    + *
  • v1 — read-only bake data: queries, layouts, on-demand bakes.
  • + *
  • v2 — break-effect providers: plugins can replace the built-in + * break animation ({@link #registerBreakEffectProvider}).
  • + *
+ */ + int VERSION = 2; /** @return {@link #VERSION} of the running implementation. */ int apiVersion(); @@ -102,4 +111,28 @@ public interface TesseraApi { * {@link BakeOutcome#NOT_CONFIGURED}. */ boolean canBakeNewBlocks(); + + /** + * Register {@code provider} to replace Tessera's built-in break animation + * (see {@link BreakEffectProvider} for the contract). One slot, + * first-wins: registration fails (with a log line naming both plugins) if + * a different provider is already installed — two plugins fighting over + * the same break can't be reconciled. The provider is unregistered + * automatically when {@code owner} disables. + * + *

Main thread only. + * + * @param owner the registering plugin, used for lifecycle + diagnostics + * @param provider the provider to install + * @return true if installed (or already installed by this exact provider) + */ + boolean registerBreakEffectProvider(Plugin owner, BreakEffectProvider provider); + + /** + * Remove {@code provider} if it is the currently registered one. Any + * breaks it still owns are despawned. Main thread only. + * + * @return true if it was the registered provider + */ + boolean unregisterBreakEffectProvider(BreakEffectProvider provider); } diff --git a/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/BreakEffectProvider.java b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/BreakEffectProvider.java new file mode 100644 index 0000000..da83894 --- /dev/null +++ b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/BreakEffectProvider.java @@ -0,0 +1,56 @@ +package org.inventivetalent.tessera.api.effect; + +import org.inventivetalent.tessera.api.TesseraApi; +import org.bukkit.plugin.Plugin; + +/** + * Extension point for replacing Tessera's built-in break animation with a + * custom effect. Register via + * {@link TesseraApi#registerBreakEffectProvider(Plugin, BreakEffectProvider)}. + * + *

Tessera keeps full ownership of everything up to the moment the collapse + * animation would play: gating (material/world lists, concurrency cap, break + * timing), blockstate variant rotation, lattice spawning, the progress-driven + * mining wave, and transport. When the block actually breaks, the registered + * provider is offered the surviving fragments instead of the built-in effect. + * + *

In progress mode (the default) the shrink wave has usually consumed part + * of the lattice by the time the block breaks — the provider receives only the + * remaining fragments, already positioned and scaled mid-wave. + * + *

All calls happen on the main server thread. + */ +public interface BreakEffectProvider { + + /** + * Offered a freshly broken block's fragment lattice. + * + *

Return {@code true} to claim the break: the provider now owns the + * animation and must eventually call {@link ShatterHandle#despawn()} + * — that releases the fragments and Tessera's concurrency slot. Tessera + * schedules a safety despawn after {@code limits.providerMaxEffectDurationMs} + * (config, default 10s) as a backstop; {@code despawn()} is idempotent, so + * a well-behaved provider is unaffected by it. + * + *

Return {@code false} to decline — Tessera's built-in collapse effect + * runs as if no provider were registered. A thrown exception is treated as + * a decline (and logged once per provider). + * + * @param handle the live fragment lattice; alive fragments only + * @param context who broke what, and how the handoff happened + * @return true if the provider claims the break and owns despawn + */ + boolean onBlockShatter(ShatterHandle handle, ShatterContext context); + + /** + * Consulted before the lattice spawns (both animation modes). + * When {@code true}, Tessera populates interior grid cells (its donor-skin + * interior fill) even if the server's {@code animation.fillInterior} + * config is off, so a claimed break can work with a solid block rather + * than a hollow shell. Only effective for {@code chunkGridSize >= 3}; + * interior cell count grows cubically with the grid size. + */ + default boolean wantsInteriorFill() { + return false; + } +} diff --git a/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterChunk.java b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterChunk.java new file mode 100644 index 0000000..938b117 --- /dev/null +++ b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterChunk.java @@ -0,0 +1,95 @@ +package org.inventivetalent.tessera.api.effect; + +import org.inventivetalent.tessera.core.ChunkCoord; +import org.inventivetalent.tessera.core.FaceDir; +import org.bukkit.util.Transformation; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import java.util.Set; + +/** + * One live fragment of a {@link ShatterHandle} — a single display entity in + * the broken block's lattice. Main-thread only. + * + *

Posing fragments. Prefer {@link #setPose}: it thinks in "fragment + * center + orientation + size" and internally re-solves the player-head + * model's render-offset compensation. The raw + * {@link #setTransformation(Transformation, int, int)} escape hatch skips that + * compensation — with it, changing the left rotation makes the cube orbit its + * render offset instead of spinning in place (see the transformation notes on + * {@code ChunkLayout}). + * + *

Both mutators drive the client's display interpolation: a positive + * {@code durationTicks} makes the client lerp from the fragment's current pose + * to the new one over that many ticks, which is how effects animate smoothly + * without per-tick server work. + */ +public interface ShatterChunk { + + /** Grid cell of this fragment (0-indexed, pre-rotation block-local). */ + ChunkCoord coord(); + + /** Fragment center in block-local [0,1]³ space, before block rotation. */ + Vector3f localCenter(); + + /** + * The block faces this fragment touches, in block-local directions + * (empty for interior fragments). Map through + * {@link ShatterHandle#blockRotation()} for world-space normals. + */ + Set outwardFaces(); + + /** True for interior fragments (no outward face). */ + boolean interior(); + + /** + * The fragment's cube center at handoff, relative to + * {@link ShatterHandle#origin()}, block rotation applied: + * {@code (0.5,0.5,0.5) + blockRotation · (localCenter − (0.5,0.5,0.5))}. + * The natural starting point for {@link #setPose} offsets. + */ + Vector3f initialCenter(); + + /** + * The fragment's current uniform scale (from the last-set transformation). + * In progress mode the mining wave may have shrunk a fragment below its + * spawn scale by handoff time. + */ + float currentScale(); + + /** False once this fragment's display entity has been removed. */ + boolean isAlive(); + + /** + * Pose this fragment as: cube centered at {@code origin() + centerOffset}, + * spun by the world-space {@code tumble} on top of the block's variant + * rotation, at uniform {@code scale}. The render-offset compensation is + * re-solved internally so the cube stays pinned to the requested center + * under any rotation. + * + * @param centerOffset target cube center relative to {@link ShatterHandle#origin()} + * @param tumble world-space rotation applied on top of the variant + * rotation; identity for "no spin". Keep successive + * poses less than 180° apart — the client lerps + * rotations along the shortest path. + * @param scale uniform scale ({@code 2/gridN} is full fragment size) + * @param delayTicks ticks before the client starts interpolating (0 = now) + * @param durationTicks client-side interpolation length in ticks; 0 snaps + */ + void setPose(Vector3f centerOffset, Quaternionf tumble, float scale, + int delayTicks, int durationTicks); + + /** The last transformation set on this fragment's display. */ + Transformation getTransformation(); + + /** + * Raw transformation write with interpolation timing. No render-offset + * compensation is applied — prefer {@link #setPose} unless you are only + * touching scale or reusing components read from {@link #getTransformation()}. + */ + void setTransformation(Transformation transformation, int delayTicks, int durationTicks); + + /** Remove just this fragment's display entity. Idempotent. */ + void despawn(); +} diff --git a/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterContext.java b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterContext.java new file mode 100644 index 0000000..44dc06e --- /dev/null +++ b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterContext.java @@ -0,0 +1,52 @@ +package org.inventivetalent.tessera.api.effect; + +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; +import org.jetbrains.annotations.Nullable; + +/** + * Break metadata passed to {@link BreakEffectProvider#onBlockShatter}. + * + * @param breaker the player who broke the block; the fragments are only + * visible to them. May be null if they logged off in the + * same tick. + * @param breakerEyeDir the breaker's normalized view direction at break time — + * the natural axis for directional effects (defensive + * copy, already normalized) + * @param blockData the broken block's full state (for sounds, particles, + * material checks) + * @param mode which of Tessera's two break paths handed the lattice + * over + */ +public record ShatterContext( + @Nullable Player breaker, + Vector breakerEyeDir, + BlockData blockData, + Mode mode) { + + /** How the break reached the provider. */ + public enum Mode { + /** + * The block broke without a progress-driven wave (post-break + * animation mode, or an effectively instant break). The lattice is + * complete and at full scale. + */ + POST_BREAK, + /** + * The progress-driven mining wave ran first (default mode). Fragments + * the wave consumed are gone; the handle carries the remainder. + */ + PROGRESS + } + + public ShatterContext { + breakerEyeDir = breakerEyeDir.clone(); + if (breakerEyeDir.lengthSquared() > 0) breakerEyeDir.normalize(); + } + + @Override + public Vector breakerEyeDir() { + return breakerEyeDir.clone(); + } +} diff --git a/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterHandle.java b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterHandle.java new file mode 100644 index 0000000..74270d4 --- /dev/null +++ b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterHandle.java @@ -0,0 +1,55 @@ +package org.inventivetalent.tessera.api.effect; + +import org.inventivetalent.tessera.core.BlockKey; +import org.bukkit.Location; +import org.joml.Quaternionf; + +import java.util.List; + +/** + * A broken block's live fragment lattice, handed to a + * {@link BreakEffectProvider} when it claims a break. Wraps Tessera's internal + * runtime handle; all methods are main-thread only. + * + *

Every fragment display entity shares one entity location — + * {@link #origin()}, the block's integer-floored lower north-west-down corner. + * Fragments are positioned purely through their transformation, which is what + * {@link ShatterChunk#setPose} manipulates. + */ +public interface ShatterHandle { + + /** The shared entity location: the block's floored NW-down corner. Cloned. */ + Location origin(); + + /** The broken block's type. */ + BlockKey blockKey(); + + /** Lattice density N (the block was split into an N×N×N grid). */ + int gridN(); + + /** + * The blockstate-variant rotation applied to the block as a whole + * (identity for blocks rendered in their canonical orientation). Already + * baked into every fragment's pose; exposed so providers can map + * block-local directions (e.g. {@link ShatterChunk#outwardFaces()}) into + * world space. Fresh copy. + */ + Quaternionf blockRotation(); + + /** + * The fragments that were alive at handoff, immutable. In progress mode, + * fragments consumed by the mining wave are not included. Individual + * fragments may still die during the effect ({@link ShatterChunk#isAlive}). + */ + List chunks(); + + /** True once {@link #despawn()} has run. */ + boolean despawned(); + + /** + * Remove every remaining fragment display and release the break's + * concurrency slot. The claiming provider must call this when its effect + * finishes. Idempotent; main thread only. + */ + void despawn(); +} diff --git a/src/main/java/org/inventivetalent/tessera/core/FaceDir.java b/tessera-api/src/main/java/org/inventivetalent/tessera/core/FaceDir.java similarity index 80% rename from src/main/java/org/inventivetalent/tessera/core/FaceDir.java rename to tessera-api/src/main/java/org/inventivetalent/tessera/core/FaceDir.java index 2276632..d729eb8 100644 --- a/src/main/java/org/inventivetalent/tessera/core/FaceDir.java +++ b/tessera-api/src/main/java/org/inventivetalent/tessera/core/FaceDir.java @@ -3,12 +3,17 @@ import org.joml.Vector3i; /** - * One of the six block-local face directions. Distinct from {@link HeadFace}, - * which is the player-head's UV layout — the FaceDir → HeadFace mapping is - * what the assemble layer's right-rotation table picks. + * One of the six block-local face directions. Distinct from the plugin's + * internal {@code HeadFace}, which is the player-head's UV layout — the + * FaceDir → HeadFace mapping is what the assemble layer's right-rotation + * table picks. * *

Axis convention matches Minecraft block-space: +X = east, +Y = up, * +Z = south. {@link #DOWN} corresponds to NBT {@code "down"}, etc. + * + *

{@link #shade()} and {@link #jsonName()} exist for the plugin's bake + * pipeline; API consumers normally only need {@link #normal()} and + * {@link #isOutwardAt(int, int, int, int)}. */ public enum FaceDir { DOWN ( 0, -1, 0), diff --git a/website/src/commands.md b/website/src/commands.md index cda3049..e0b647f 100644 --- a/website/src/commands.md +++ b/website/src/commands.md @@ -17,15 +17,20 @@ subcommands — anyone with `tessera.command` can run every subcommand, including the bake-time tuners that invalidate the registry. Restrict the node to operators or a tightly-scoped admin group accordingly. -## `/tessera test [material] [static]` +## `/tessera test [material] [static|builtin]` Bake the requested material if it isn't already in the registry, then spawn a `FakeBlock` at the cell you're looking at. - `material` — defaults to `stone`. With or without `minecraft:` prefix. -- `static` — optional flag. Spawns the FakeBlock without the shrink - animation and keeps it alive for five minutes so you can walk around - and inspect it. +- `static` — optional flag. Spawns the FakeBlock without any animation + and keeps it alive for five minutes so you can walk around and + inspect it. +- `builtin` — optional flag. Forces the built-in shrink animation even + when another plugin has registered a + [break-effect provider](/extension-api#replace-the-break-animation-api-v2); + without it the test goes through the same effect selection as a real + break. If the material isn't in `heads.json` and `mineskin.apiKey` is unset, the command refuses with a hint to configure the key first. diff --git a/website/src/extension-api.md b/website/src/extension-api.md index 932ce12..3f0074e 100644 --- a/website/src/extension-api.md +++ b/website/src/extension-api.md @@ -140,6 +140,69 @@ public void onBaked(TesseraBlockBakedEvent event) { } ``` +## Replace the break animation (API v2) + +A plugin can take over the animation Tessera plays when a block breaks by +registering a **break-effect provider**. Tessera still does everything up to +that moment — gating, variant rotation, lattice spawning, the progress-driven +mining wave, per-viewer transport — and then offers the surviving fragments to +your provider instead of running its built-in collapse: + +```java +TesseraApi tessera = Tessera.api(); +if (tessera == null || tessera.apiVersion() < 2) { + getLogger().severe("Tessera with API v2 required"); + return; +} +tessera.registerBreakEffectProvider(this, new BreakEffectProvider() { + @Override + public boolean onBlockShatter(ShatterHandle handle, ShatterContext ctx) { + for (ShatterChunk chunk : handle.chunks()) { + // animate: chunk.setPose(centerOffset, tumble, scale, delay, interp) + } + // you now own the fragments — despawn when the effect is done: + Bukkit.getScheduler().runTaskLater(plugin, handle::despawn, 40L); + return true; // claimed; false = let the built-in effect run + } + + @Override + public boolean wantsInteriorFill() { + return true; // solid lattice even if animation.fillInterior is off + } +}); +``` + +The contract, briefly: + +- **One slot, first-wins.** A second plugin's registration fails with a log + line; two providers fighting over the same break can't be reconciled. The + provider is detached automatically when its plugin disables. +- **Claim = own the despawn.** Return `true` and you must eventually call + `handle.despawn()` — that removes the fragments and frees Tessera's + concurrency slot. As a backstop, Tessera force-despawns a claimed break + after `limits.providerMaxEffectDurationMs` (config, default 10s); + `despawn()` is idempotent so a well-behaved provider never notices. +- **Decline = built-in effect.** Return `false` (or throw — logged once) and + Tessera animates the break exactly as if no provider were registered. +- **Progress mode hands over the remainder.** With the default + `animation.mode: progress`, the mining wave consumes fragments while the + player digs; on the real break your provider receives only what survived, + positioned and scaled mid-wave. `ShatterContext.mode()` tells you which + path you're on. +- **Pose, don't recompute.** `ShatterChunk.setPose(centerOffset, tumble, + scale, delayTicks, durationTicks)` places a fragment's cube center relative + to `handle.origin()` with a world-space rotation on top of the blockstate + orientation. The player-head model's render-offset compensation is + re-solved internally — with the raw `setTransformation` escape hatch, + rotating the left rotation makes fragments orbit off-center. A positive + `durationTicks` rides the client's display interpolation, so a handful of + poses per second animates smoothly with no per-tick server work. +- **Main thread only**, like the rest of the break pipeline. Fragments are + visible only to the breaker (`ShatterContext.breaker()`). + +In-game check: `/tessera test stone` routes through the registered provider; +`/tessera test stone builtin` forces the built-in effect for comparison. + ## Versioning `TesseraApi.VERSION` (and `apiVersion()`) is bumped on incompatible changes. The