diff --git a/README.md b/README.md
index b5af078..0374206 100644
--- a/README.md
+++ b/README.md
@@ -219,6 +219,15 @@ diagnostics.reportUnknownBlock("mod:strange_block"); // true the first time, f
pre-26.1 layout, and `openRegionCount()` how many files are open right now. `close()` flushes every
one of them and is what `ownsLoader(true)` calls for you.
+The version guard and the unknown-entry fallback above are both policies, not fixed behaviour:
+`ChunkVersionPolicy` decides whether a chunk is readable at all, `UnknownEntryPolicy` decides what an
+unknown block or biome becomes, and `falco-anvil` ships a default for each — `DefaultChunkVersionPolicy`
+is the 21w43a guard, `DefaultUnknownEntryPolicy` is the air/plains substitution — discovered from the
+classpath via `ServiceLoader` unless the builder's `versionPolicy()`/`unknownEntryPolicy()` slots are
+used instead. The guard can be removed: `versionPolicy(null)` turns the check off, and nothing stands
+in for it — a loader with no guard reads a pre-21w43a world as air again, with no error and no log
+line.
+
### falco-light — block and sky light
Three entry points, in order of how much they do:
diff --git a/docs/superpowers/plans/2026-08-04-anvil-extension-points.md b/docs/superpowers/plans/2026-08-04-anvil-extension-points.md
new file mode 100644
index 0000000..7c87a8d
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-04-anvil-extension-points.md
@@ -0,0 +1,829 @@
+# Anvil extension points — implementation plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Two hard-wired policies in `falco-anvil` — which worlds are readable, and what an unknown
+palette entry becomes — become services a caller can replace or discover.
+
+**Architecture:** One shared resolution helper implements the discovery rules once. Two service
+interfaces use it: `ChunkVersionPolicy` (the guard from #45, moved rather than rewritten) and
+`UnknownEntryPolicy` (consulted where the two palette resolvers substitute today). Each has a
+built-in implementation, an explicit builder slot, and a `discover…()` slot.
+
+**Tech Stack:** Java 25, Gradle, `java.util.ServiceLoader` (plain classpath, no JPMS), Adventure NBT,
+JUnit 5.
+
+**Spec:** `docs/superpowers/specs/2026-08-04-anvil-extension-points-design.md`
+
+**Base:** `main` **after #45 is merged**. This plan moves the body of `requireReadableVersion`, which
+only exists on that branch. Do not start before the merge.
+
+## Global Constraints
+
+- All three interfaces live in `net.onelitefeather.falco.anvil`. No new module.
+- **No `module-info.java`** is added. Plain classpath services.
+- `checkApiCompatibility` runs: every signature change is additive. **`PaletteEntryResolver.toId` and `toEntry` keep their exact signatures** — see the decision below.
+- Javadoc under `-Werror`, `@param`/`@return`/`@throws` complete, `@since 1.2.0` on new members, `@version` of every changed type raised one minor.
+- Builders are immutable: a new field means the constructor, `build()`, and **every** existing setter.
+- Test names read as sentences. Tests are package-private, plain JUnit assertions.
+- Conventional Commits, lower case.
+- No timing figure anywhere.
+- Check `uptime` before any test run and record it. Counts come from the JUnit XML, not the console.
+
+## Two decisions this plan makes that the spec left open
+
+Both are recorded here because the spec's "Open for the plan" section names them, and because an
+implementer would otherwise have to invent them.
+
+**1. `ChunkVersionPolicy` does not count and does not log.** The spec's sketch was
+`check(CompoundBinaryTag, int)`, but the body being moved reads three instance fields of the loader:
+`minimumDataVersion`, `diagnostics` and `regionDirectory`. Passing all three into a service would put
+the loader's infrastructure into a public contract. Instead the policy **only decides and throws**;
+the loader catches, counts and logs, deriving the reported version from the compound it already has.
+This keeps the interface free of `AnvilDiagnostics` and keeps every diagnostic in one place.
+
+**2. `UnknownEntryPolicy` throws an *unchecked* fault.** `PaletteEntryResolver.toId` is
+`int toId(String, CompoundBinaryTag)` with no `throws` clause, and it is published API on a 1.0.0
+artefact. Adding a checked exception to it would break every implementor. `AnvilChunkException`
+already exists as `non-sealed class … extends RuntimeException implements AnvilFault`, so a policy
+that refuses throws that, and no published signature changes.
+
+## File Structure
+
+| File | Responsibility | Change |
+| --- | --- | --- |
+| `…/anvil/ServiceResolution.java` | The discovery rules, once | Create (package-private) |
+| `…/anvil/ChunkVersionPolicy.java` | The readable-world contract | Create |
+| `…/anvil/DefaultChunkVersionPolicy.java` | #45's body, moved | Create |
+| `…/anvil/UnknownEntryPolicy.java` | The unknown-entry contract | Create |
+| `…/anvil/DefaultUnknownEntryPolicy.java` | Air and plains, as today | Create |
+| `…/anvil/FalcoAnvilLoader.java` | Loader and builder | Modify: two field pairs, four builder slots, `requireReadableVersion` becomes a call |
+| `…/anvil/BlockPaletteResolver.java` | Block palette | Modify: substitution goes through the policy |
+| `…/anvil/BiomePaletteResolver.java` | Biome palette | Modify: same |
+| `falco-anvil/src/main/resources/META-INF/services/…ChunkVersionPolicy` | `falco-anvil`'s own registration | Create |
+| Five test classes | | Create/modify per task |
+
+---
+
+### Task 1: The resolution rules, once
+
+**Files:**
+- Create: `falco-anvil/src/main/java/net/onelitefeather/falco/anvil/ServiceResolution.java`
+- Test: `falco-anvil/src/test/java/net/onelitefeather/falco/anvil/ServiceResolutionTest.java`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `static
+ * A policy only decides. It does not count and it does not log: the loader catches the failure,
+ * records it in its {@link AnvilDiagnostics} and writes the log line, so every diagnostic of a load
+ * stays in one place and this contract stays free of the loader's infrastructure.
+ *
+ * Returning an id substitutes it; throwing {@link AnvilChunkException} fails the chunk. Substituting
+ * is right for a server that wants a world to stay loadable and wrong for a tool that converts one,
+ * which is why this is a policy and not a constant.
+ *
- * A biome the registry does not know is replaced with the plains biome instead of failing, which
- * follows the behaviour of the built-in loader. Every replaced name is reported once through the
- * diagnostics.
+ * A biome the registry does not know is handed to an {@link UnknownEntryPolicy} instead of failing
+ * outright: the default substitutes plains, which follows the behaviour of the built-in loader, but
+ * a caller that converts or checks a world can configure a policy which refuses it instead. Every
+ * unknown name is reported once through the diagnostics, regardless of what the policy does with it.
*
* The registry is resolved on the first use instead of in a static initializer or in the
@@ -32,7 +33,7 @@
*
+ * Package-private on purpose: nothing in this module needs both a custom policy and a custom
+ * registry at once, and there is no reason to promise that combination as public API before a
+ * caller actually needs it. It exists for this package's own tests, which use it to exercise
+ * {@link #toId(String, CompoundBinaryTag)} against a fake registry without a running server.
+ *
- * An entry the server does not know is replaced with air instead of failing. A world can hold
- * blocks of a mod or of a newer game version and rejecting the whole chunk over a single unknown
- * block would lose far more data than it protects. Every replaced name is reported once through
- * the diagnostics so the problem stays visible without flooding the log.
+ * An entry the server does not know is handed to an {@link UnknownEntryPolicy} instead of failing
+ * outright: the default substitutes air, which keeps a world holding blocks of a mod or of a newer
+ * game version loadable instead of losing a whole chunk over a single unknown block, but a caller
+ * that converts or checks a world can configure a policy which refuses it instead. Every unknown
+ * name is reported once through the diagnostics so the problem stays visible without flooding the
+ * log, regardless of what the policy does with it.
*
@@ -28,7 +30,7 @@
*
+ * A policy only decides. It does not count and it does not log: the loader catches the failure,
+ * records it in its {@link AnvilDiagnostics} and writes the log line, so every diagnostic of a load
+ * stays in one place and this contract stays free of the loader's infrastructure.
+ *
+ * Called from several threads at once. The policy is resolved once, when the loader is built,
+ * and every load after that consults the same instance — including every parallel load, since
+ * {@link FalcoAnvilLoader#supportsParallelLoading()} reports {@code true}. An implementation
+ * therefore has to be thread-safe on its own; the loader takes no lock around the call.
+ * {@link DefaultChunkVersionPolicy}, the shipped default, holds no state and needs none.
+ *
+ * This is the guard against a specific piece of data loss: before snapshot {@code 21w43a}, a
+ * chunk's block data lived under a {@code Level} compound instead of {@code sections} on the root.
+ * Reading such a chunk with a loader that only looks for {@code sections} on the root does not
+ * fail, it silently decodes to an empty section list — a chunk of air. This policy is what turns
+ * that silent data loss into a thrown exception.
+ *
+ * This type is experimental, like everything else in this package.
+ *
+ * The layout is checked before the version, because a version number is a claim about the data
+ * while the layout is the data: a chunk may carry no version at all, and one that carries a
+ * version may not hold what that version promises. A root compound without {@code sections} but
+ * with a {@code Level} compound is the pre-1.18 shape, which would otherwise decode to an empty
+ * section list and reach the caller as a chunk of air.
+ *
+ * A missing {@code DataVersion} is the one case that is not a rejection: a tool which writes
+ * {@code sections} on the root but never learned to stamp a version has to keep loading, or a
+ * whole category of externally-written world becomes unreadable. A key that is present but is not
+ * the number it claims to be, and a key that holds a negative number, are both a different
+ * situation from absent: something wrote a value there and it does not describe a version this
+ * loader can trust, so both are refused rather than waved through the same path as "nothing was
+ * ever written".
+ *
+ * Both names are plain string literals. This policy resolves nothing itself — {@link
+ * UnknownEntryPolicy} hands back a name, not an id, precisely so that the shipped default needs
+ * neither Minestom nor a registry to answer. The resolver that consults this policy already holds
+ * the registry it needs to turn {@code "minecraft:air"} or {@code "minecraft:plains"} into an id,
+ * because it just used that same registry to look the original, unknown name up.
+ *
+ * This type is experimental, like everything else in this package.
+ *
+ * Always returns {@code "minecraft:air"}.
+ *
+ * Always returns {@code "minecraft:plains"}.
+ *
+ * Resolved once, in the constructor, through {@link ServiceResolution#choose(Class, Object, + * boolean, Class)}, naming {@link DefaultChunkVersionPolicy} as the shipped default — so a + * foreign {@link ChunkVersionPolicy} registered through {@code META-INF/services} is chosen over + * it, and only a second foreign provider is refused as ambiguous. A builder which never touches + * {@link Builder#versionPolicy(ChunkVersionPolicy)} or {@link Builder#discoverVersionPolicy()} + * discovers a policy through the classpath by default, which is what keeps every loader built + * the way earlier versions built one refusing the same chunks it always refused. Calling + * {@code versionPolicy(null)} is the only way to leave this field null, and + * {@link #checkVersion(CompoundBinaryTag)} treats that as "check nothing" rather than + * substituting the default itself. + *
+ * + * @since 2.1.0 + */ + private final @Nullable ChunkVersionPolicy versionPolicy; + + /** + * The policy consulted for a palette entry the running server does not know, resolved once in + * the constructor the same way {@link #versionPolicy} is: through {@link + * ServiceResolution#choose(Class, Object, boolean, Class)}, naming {@link + * DefaultUnknownEntryPolicy} as the shipped default. + *+ * Unlike {@link #versionPolicy}, this field is never null. A builder which never touches + * {@link Builder#unknownEntryPolicy(UnknownEntryPolicy)} or + * {@link Builder#discoverUnknownEntryPolicy()} discovers a policy from the classpath by default, + * falling back to {@link DefaultUnknownEntryPolicy} when the classpath registers none — there is + * no "consult nothing" state for this decision the way {@code versionPolicy(null)} lets a caller + * skip the version check entirely, because an id is always required for the loader to keep + * decoding. + *
+ * + * @since 2.1.0 + */ + private final UnknownEntryPolicy unknownEntryPolicy; + /** * Where failures are reported, or null for the exception manager of the running server. *
@@ -158,6 +194,8 @@ public final class FalcoAnvilLoader implements ChunkLoader, AutoCloseable {
*
* @param worldRoot the root directory of the world
* @param dimension the key of the dimension the loader reads and writes
+ * @throws IllegalStateException if the classpath registers more than one foreign
+ * {@link ChunkVersionPolicy}
*/
public FalcoAnvilLoader(Path worldRoot, Key dimension) {
this(worldRoot, dimension, DEFAULT_OPEN_REGION_LIMIT);
@@ -176,6 +214,8 @@ public FalcoAnvilLoader(Path worldRoot, Key dimension) {
* @param dimension the key of the dimension the loader reads and writes
* @param openRegionLimit the amount of region files the loader keeps open
* @throws IllegalArgumentException if the limit is not positive
+ * @throws IllegalStateException if the classpath registers more than one foreign
+ * {@link ChunkVersionPolicy}
*/
public FalcoAnvilLoader(Path worldRoot, Key dimension, int openRegionLimit) {
this(worldRoot, dimension, builder().openRegionLimit(openRegionLimit));
@@ -207,11 +247,38 @@ private FalcoAnvilLoader(Path worldRoot, Key dimension, Builder settings) {
this.legacyLayout = resolved.legacyLayout();
this.dimensionLabel = dimension.asString();
this.diagnostics = effective;
+ // A caller who names both a policy and their own resolver has built a loader in which the
+ // policy can never be reached: the resolver a builder is handed is used exactly as given,
+ // never rebuilt around the configured policy, so unknownEntryPolicy() below would keep
+ // reporting a policy that the actual decoding path never consults. Refusing the combination
+ // holds this to the same standard ServiceResolution.choose already applies to explicit
+ // configuration versus discovery: two conflicting explicit decisions are refused outright,
+ // not silently reconciled by picking one of them. A resolver configured without touching
+ // either unknownEntryPolicy slot is unaffected, because unknownEntryPolicyConfigured stays
+ // false for a builder that never called unknownEntryPolicy(...) or
+ // discoverUnknownEntryPolicy() itself.
+ if (settings.unknownEntryPolicyConfigured && (settings.blockResolver != null || settings.biomeResolver != null)) {
+ throw new IllegalStateException(
+ "An UnknownEntryPolicy was configured together with a custom "
+ + (settings.blockResolver != null ? "blockResolver" : "biomeResolver")
+ + ". A resolver supplied through the builder is used exactly as given and never "
+ + "sees the configured policy, so it would silently keep its own fallback instead. "
+ + "Pass the policy into the resolver you build instead, e.g. "
+ + "new BlockPaletteResolver(diagnostics, policy), and stop configuring it on this "
+ + "builder."
+ );
+ }
+ UnknownEntryPolicy resolvedUnknownEntryPolicy = ServiceResolution.choose(
+ UnknownEntryPolicy.class, settings.unknownEntryPolicy, settings.discoverUnknownEntryPolicy,
+ DefaultUnknownEntryPolicy.class);
+ this.unknownEntryPolicy = resolvedUnknownEntryPolicy == null
+ ? new DefaultUnknownEntryPolicy()
+ : resolvedUnknownEntryPolicy;
this.blockResolver = settings.blockResolver == null
- ? new BlockPaletteResolver(effective)
+ ? new BlockPaletteResolver(effective, this.unknownEntryPolicy)
: settings.blockResolver;
this.biomeResolver = settings.biomeResolver == null
- ? new BiomePaletteResolver(effective)
+ ? new BiomePaletteResolver(effective, this.unknownEntryPolicy)
: settings.biomeResolver;
this.regions = new ConcurrentHashMap<>();
this.trackedChunks = new ConcurrentHashMap<>();
@@ -219,6 +286,9 @@ private FalcoAnvilLoader(Path worldRoot, Key dimension, Builder settings) {
this.dataVersion = settings.dataVersion;
this.minimumDataVersion = settings.minimumDataVersion;
this.exceptionHandler = settings.exceptionHandler;
+ this.versionPolicy = ServiceResolution.choose(
+ ChunkVersionPolicy.class, settings.versionPolicy, settings.discoverVersionPolicy,
+ DefaultChunkVersionPolicy.class);
this.closeLock = new ReentrantLock();
// Which directory was chosen, and how many region files are in it, is the first thing
@@ -226,12 +296,13 @@ private FalcoAnvilLoader(Path worldRoot, Key dimension, Builder settings) {
// two layouts happens invisibly, and a world whose files sit in the other one looks exactly
// like a world which is empty.
LOGGER.info(
- "Opening the anvil loader for region={} layout={} exists={} regionFiles={} dim={}",
+ "Opening the anvil loader for region={} layout={} exists={} regionFiles={} dim={} versionPolicy={}",
this.regionDirectory,
this.legacyLayout ? "legacy
* The builder reaches the values the constructors set for themselves — the compression level,
- * the diagnostics, both palette resolvers, the save parallelism and the data version. The world
- * directory and the dimension are not among them: they are required, so they sit in
+ * the diagnostics, both palette resolvers, the save parallelism and the data version. It also
+ * defaults to discovering a {@link ChunkVersionPolicy} and an {@link UnknownEntryPolicy} from
+ * the classpath, which is what keeps every loader built through a constructor rather than an
+ * explicit {@link Builder#versionPolicy(ChunkVersionPolicy)} or
+ * {@link Builder#unknownEntryPolicy(UnknownEntryPolicy)} call behaving the way it always did.
+ * The world directory and the dimension are not among them: they are required, so they sit in
* {@link Builder#build(Path, Key)} rather than in a slot.
*
+ * Do not combine this with {@link #unknownEntryPolicy(UnknownEntryPolicy)} or + * {@link #discoverUnknownEntryPolicy()}. Whatever policy those slots resolve to is never + * handed to a resolver supplied here — {@link #build(Path, Key)} refuses the combination + * rather than build a loader whose configured policy is silently unreachable. Pass the + * policy into your own resolver instead, the same way the shipped resolvers accept one in + * their constructor. + *
* * @param blockResolver the resolver for block palette entries * @return a new builder with this value @@ -521,14 +649,21 @@ public Builder blockResolver(PaletteEntryResolver blockResolver) { this.diagnostics, blockResolver, this.biomeResolver, - this.exceptionHandler); + this.exceptionHandler, + this.versionPolicy, + this.discoverVersionPolicy, + this.unknownEntryPolicy, + this.discoverUnknownEntryPolicy, + this.unknownEntryPolicyConfigured); } /** * Sets the resolver which turns biome palette entries into ids. ** The same caveat as {@link #blockResolver(PaletteEntryResolver)}: a resolver of your own - * counts past the diagnostics of the loader. + * counts past the diagnostics of the loader, and the same restriction against combining it + * with {@link #unknownEntryPolicy(UnknownEntryPolicy)} or + * {@link #discoverUnknownEntryPolicy()} applies, for the same reason. *
* * @param biomeResolver the resolver for biome palette entries @@ -544,7 +679,12 @@ public Builder biomeResolver(PaletteEntryResolver biomeResolver) { this.diagnostics, this.blockResolver, biomeResolver, - this.exceptionHandler); + this.exceptionHandler, + this.versionPolicy, + this.discoverVersionPolicy, + this.unknownEntryPolicy, + this.discoverUnknownEntryPolicy, + this.unknownEntryPolicyConfigured); } /** @@ -576,7 +716,186 @@ public Builder exceptionHandler(Consumer+ * Calling this slot always turns discovery off, whatever value is passed: an explicit + * decision about the policy — including the explicit decision "none" — has to win over the + * classpath without {@link ServiceResolution#choose(Class, Object, boolean, Class)} seeing + * both set at once and refusing to guess between them. A builder that never calls this slot + * keeps discovering the default instead, which is what {@link #builder()} starts with. + *
+ *+ * Passing {@code null} is not "use the default": it is "check nothing". A pre-{@code + * 21w43a} chunk that would otherwise be refused loads as a chunk of air instead, exactly as + * this loader read one before {@link ChunkVersionPolicy} existed. That cost is deliberate — + * see {@code testWithoutAnyPolicyALegacyChunkIsNotChecked} in the loader's integration + * tests for the case that documents it. + *
+ *+ * An explicit instance passed here is shared by every loader this builder builds afterward, + * the same way an explicit {@link #diagnostics(AnvilDiagnostics)} instance is — and + * {@link ChunkVersionPolicy} is called from every one of those loaders' parallel loads at + * once, so it has to tolerate that sharing the way {@link AnvilDiagnostics} already does. + *
+ * + * @param versionPolicy the policy to consult before a chunk is decoded, or null to consult + * none + * @return a new builder with this value + * @since 2.1.0 + */ + @Contract(value = "_ -> new", pure = true) + public Builder versionPolicy(@Nullable ChunkVersionPolicy versionPolicy) { + return new Builder(this.openRegionLimit, + this.compressionLevel, + this.saveParallelism, + this.dataVersion, + this.minimumDataVersion, + this.diagnostics, + this.blockResolver, + this.biomeResolver, + this.exceptionHandler, + versionPolicy, + false, + this.unknownEntryPolicy, + this.discoverUnknownEntryPolicy, + this.unknownEntryPolicyConfigured); + } + + /** + * Asks the loader to discover its {@link ChunkVersionPolicy} from the classpath instead of + * using an explicit instance. + *+ * This is what {@link #builder()} already defaults to, so calling it only matters after an + * earlier {@link #versionPolicy(ChunkVersionPolicy)} call on the same chain, to undo it. The + * shipped {@link DefaultChunkVersionPolicy} steps aside for a single registered foreign + * provider; a classpath that registers more than one foreign provider makes + * {@link Builder#build(Path, Key)} throw {@link IllegalStateException} rather than guess + * between them. + *
+ * + * @return a new builder with this value + * @since 2.1.0 + */ + @Contract(value = "-> new", pure = true) + public Builder discoverVersionPolicy() { + return new Builder(this.openRegionLimit, + this.compressionLevel, + this.saveParallelism, + this.dataVersion, + this.minimumDataVersion, + this.diagnostics, + this.blockResolver, + this.biomeResolver, + this.exceptionHandler, + null, + true, + this.unknownEntryPolicy, + this.discoverUnknownEntryPolicy, + this.unknownEntryPolicyConfigured); + } + + /** + * Sets the policy consulted for a palette entry the running server does not know, or clears + * it so the classpath default is used instead. + *+ * Calling this slot always turns discovery off, whatever value is passed, for the same + * reason {@link #versionPolicy(ChunkVersionPolicy)} does: an explicit decision has to win + * over the classpath without {@link ServiceResolution#choose(Class, Object, boolean, Class)} + * seeing both set at once and refusing to guess between them. A builder that never calls + * this slot keeps discovering the default instead, which is what {@link #builder()} starts + * with. + *
+ *+ * Unlike {@link #versionPolicy(ChunkVersionPolicy)}, passing {@code null} here is not "check + * nothing": {@link #build(Path, Key)} always resolves a usable policy, falling back to + * {@link DefaultUnknownEntryPolicy} when neither an explicit instance nor a foreign + * classpath provider is found, because a resolver always needs an id for the entry it could + * not otherwise decode. + *
+ * + *+ * Do not combine this with {@link #blockResolver(PaletteEntryResolver)} or + * {@link #biomeResolver(PaletteEntryResolver)}. A resolver supplied through either of + * those slots is used exactly as given and is never rebuilt around this policy, so + * {@link #build(Path, Key)} refuses the combination instead of building a loader whose + * configured policy would never actually run. + *
+ *+ * An explicit instance passed here is shared by every loader this builder builds afterward, + * the same way an explicit {@link #diagnostics(AnvilDiagnostics)} instance is — and + * {@link UnknownEntryPolicy} is called from every one of those loaders' parallel loads at + * once, so it has to tolerate that sharing the way {@link AnvilDiagnostics} already does. + *
+ * + * @param unknownEntryPolicy the policy to consult for an unknown palette entry, or null to + * fall back to the classpath default + * @return a new builder with this value + * @since 2.1.0 + */ + @Contract(value = "_ -> new", pure = true) + public Builder unknownEntryPolicy(@Nullable UnknownEntryPolicy unknownEntryPolicy) { + return new Builder(this.openRegionLimit, + this.compressionLevel, + this.saveParallelism, + this.dataVersion, + this.minimumDataVersion, + this.diagnostics, + this.blockResolver, + this.biomeResolver, + this.exceptionHandler, + this.versionPolicy, + this.discoverVersionPolicy, + unknownEntryPolicy, + false, + true); + } + + /** + * Asks the loader to discover its {@link UnknownEntryPolicy} from the classpath instead of + * using an explicit instance. + *+ * This is what {@link #builder()} already defaults to, so calling it only matters after an + * earlier {@link #unknownEntryPolicy(UnknownEntryPolicy)} call on the same chain, to undo it. + * The shipped {@link DefaultUnknownEntryPolicy} steps aside for a single registered foreign + * provider; a classpath that registers more than one foreign provider makes + * {@link Builder#build(Path, Key)} throw {@link IllegalStateException} rather than guess + * between them. + *
+ *+ * Calling this together with {@link #blockResolver(PaletteEntryResolver)} or + * {@link #biomeResolver(PaletteEntryResolver)} is refused by {@link #build(Path, Key)} for + * the same reason {@link #unknownEntryPolicy(UnknownEntryPolicy)} is: a resolver supplied + * through either of those slots never sees whatever this discovers. + *
+ * + * @return a new builder with this value + * @since 2.1.0 + */ + @Contract(value = "-> new", pure = true) + public Builder discoverUnknownEntryPolicy() { + return new Builder(this.openRegionLimit, + this.compressionLevel, + this.saveParallelism, + this.dataVersion, + this.minimumDataVersion, + this.diagnostics, + this.blockResolver, + this.biomeResolver, + this.exceptionHandler, + this.versionPolicy, + this.discoverVersionPolicy, + null, + true, + true); } /** @@ -590,6 +909,16 @@ public Builder exceptionHandler(Consumer+ * Package-private for the same reason as {@link #minimumDataVersion()}: this exists for the + * builder's own pass-through tests, not for a caller outside this package. + *
+ * + * @return the resolved policy, or null if the loader checks no chunk version at all + * @since 2.1.0 + */ + @Contract(pure = true) + @Nullable ChunkVersionPolicy versionPolicy() { + return this.versionPolicy; + } + + /** + * Returns the policy this loader resolved for an unknown palette entry. + *+ * Package-private for the same reason as {@link #versionPolicy()}: this exists for the + * builder's own pass-through tests, not for a caller outside this package. + *
+ * + * @return the resolved policy, never null + * @since 2.1.0 + */ + @Contract(pure = true) + UnknownEntryPolicy unknownEntryPolicy() { + return this.unknownEntryPolicy; + } + /** * Closes every region file the loader opened and reports a summary of its work. *@@ -1411,61 +1772,54 @@ public int openRegionCount() { } /** - * Refuses a chunk which comes from a version this loader cannot read. - *
- * The layout is checked before the version, because a version number is a claim about the data - * while the layout is the data: a chunk may carry no version at all, and one that carries a - * version may not hold what that version promises. A root compound without {@code sections} but - * with a {@code Level} compound is the pre-1.18 shape, which would otherwise decode to an empty - * section list and reach the caller as a chunk of air. - *
+ * Consults {@link #versionPolicy} about a chunk, and reports and counts a refusal before it + * reaches the caller. *- * A missing {@code DataVersion} is the one case that is not a rejection: a tool which writes - * {@code sections} on the root but never learned to stamp a version has to keep loading, or a - * whole category of externally-written world becomes unreadable. A key that is present but is not - * the number it claims to be, and a key that holds a negative number, are both a different - * situation from absent: something wrote a value there and it does not describe a version this - * loader can trust, so both are refused rather than waved through the same path as "nothing was - * ever written". + * The policy only decides and throws; it does not know about {@link AnvilDiagnostics} or the + * logger, on purpose — see {@link ChunkVersionPolicy}. Counting and logging the refusal is + * therefore the loader's job, done here rather than duplicated at every call site, and done + * only when {@link #versionPolicy} is not null: the caller at {@link #loadChunk(Instance, int, + * int)} already guards the call for that reason. *
* * @param data the root compound of the chunk - * @throws ChunkDataException if the chunk cannot be read + * @throws ChunkDataException if the policy refuses the chunk */ - private void requireReadableVersion(CompoundBinaryTag data) throws ChunkDataException { - boolean versionMissing = data.get(DATA_VERSION_KEY) == null; - // A stored value that is not a number falls back to the same -1 as an absent key, but the - // two are not the same failure: this flag is what lets the exception below say "not a - // number" instead of misreporting a value ("-1") that was never actually stored. - boolean versionMistyped = !versionMissing && !(data.get(DATA_VERSION_KEY) instanceof NumberBinaryTag); - int version = NbtReads.optionalInteger(data, DATA_VERSION_KEY, -1); - String reported = versionMissing ? AnvilDiagnostics.UNKNOWN_DATA_VERSION : Integer.toString(version); - boolean legacyChunkLayout = !(data.get(SECTIONS_KEY) instanceof ListBinaryTag) - && NbtReads.optionalCompound(data, LEGACY_LEVEL_KEY) != null; - - if (!legacyChunkLayout && (versionMissing || version >= this.minimumDataVersion)) { - return; - } - - if (this.diagnostics.reportUnsupportedChunkVersion(reported)) { - LOGGER.warn( - "Refusing a chunk from data version {} in {}: {}", - reported, this.regionDirectory, - legacyChunkLayout - ? "the chunk data sits under Level, which this loader does not read" - : "the loader accepts " + this.minimumDataVersion + " and above" - ); + private void checkVersion(CompoundBinaryTag data) throws ChunkDataException { + try { + this.versionPolicy.check(data, this.minimumDataVersion); + } catch (ChunkDataException failure) { + String reported = reportedDataVersion(data); + + if (this.diagnostics.reportUnsupportedChunkVersion(reported)) { + LOGGER.warn( + "Refusing a chunk from data version {} in {}: {}", + reported, this.regionDirectory, failure.getMessage() + ); + } + throw failure; } + } - throw new ChunkDataException( - ChunkDataException.Reason.UNSUPPORTED_CHUNK_VERSION, - legacyChunkLayout - ? "The chunk stores its data under Level, which means a version before 1.18" - : versionMistyped - ? "The chunk does not store its DataVersion as a number" - : "The chunk stores data version " + version - + " but the loader accepts " + this.minimumDataVersion + " and above" - ); + /** + * Renders the stored {@code DataVersion} of a chunk the way the version breakdown of + * {@link AnvilDiagnostics} groups it: by the number it holds, or by + * {@link AnvilDiagnostics#UNKNOWN_DATA_VERSION} for a chunk which stores none. + *+ * This mirrors only the presentation the guard used before it became a policy, not its + * decision: a key that is present but not a number renders as {@code "-1"} here exactly as it + * always did, because {@link NbtReads#optionalInteger(CompoundBinaryTag, String, int)} falls + * back to that default for a mistyped value the same way it does for a missing one. + *
+ * + * @param data the root compound of the chunk + * @return the data version to report, or {@link AnvilDiagnostics#UNKNOWN_DATA_VERSION} + */ + @Contract(pure = true) + private static String reportedDataVersion(CompoundBinaryTag data) { + return data.get(DATA_VERSION_KEY) == null + ? AnvilDiagnostics.UNKNOWN_DATA_VERSION + : Integer.toString(NbtReads.optionalInteger(data, DATA_VERSION_KEY, -1)); } /** diff --git a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/PaletteEntryResolver.java b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/PaletteEntryResolver.java index b78a4c3..11384fe 100644 --- a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/PaletteEntryResolver.java +++ b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/PaletteEntryResolver.java @@ -20,7 +20,7 @@ * * * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 0.1.0 */ @ApiStatus.Experimental @@ -29,15 +29,22 @@ public interface PaletteEntryResolver { /** * Resolves the id which belongs to the given palette entry. *- * An implementation must not fail for an unknown name. A world can hold entries of a mod or of - * a newer game version and losing a whole chunk over a single unknown entry would destroy more - * data than it protects. An implementation is expected to return a replacement id instead and - * to report the name to the caller. + * An implementation is allowed to fail for an unknown name — unchecked, since this method + * declares no {@code throws} clause. {@link BlockPaletteResolver} and + * {@link BiomePaletteResolver} both delegate that decision to a caller-supplied + * {@link UnknownEntryPolicy} rather than deciding it themselves: the shipped default + * substitutes a replacement id, which keeps a world holding entries of a mod or of a newer game + * version loadable instead of losing a whole chunk over a single unknown entry, but a policy + * configured to refuse instead throws {@link AnvilChunkException} from here. A caller of + * {@code toId} therefore has to be ready for either outcome, depending on how the resolver it + * holds was configured. *
* * @param name the name of the palette entry * @param properties the properties of the palette entry or null if it carries none * @return the id which belongs to the entry + * @throws AnvilChunkException if the implementation was configured to refuse an unknown name + * instead of substituting one */ int toId(String name, @Nullable CompoundBinaryTag properties); diff --git a/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/ServiceResolution.java b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/ServiceResolution.java new file mode 100644 index 0000000..0d66d88 --- /dev/null +++ b/falco-anvil/src/main/java/net/onelitefeather/falco/anvil/ServiceResolution.java @@ -0,0 +1,144 @@ +package net.onelitefeather.falco.anvil; + +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.ServiceLoader; + +/** + * Resolves the two decisions that currently sit hardcoded in the loader as classpath services: + * which worlds are readable, and what an unknown block becomes. This type builds the resolution + * rules once, so the extension points built on top of it do not each reinvent them. + *+ * A caller either configures an instance explicitly, or asks for classpath discovery, but not both + * at once: {@link #choose(Class, Object, boolean)} refuses the combination outright rather than + * silently preferring one side. Discovery itself, in {@link #discover(Class)}, refuses to guess + * between more than one registered provider — except that a module's own shipped default, named + * through {@link #discover(Class, Class)}, always steps aside for a foreign one rather than + * counting as a second vote. Two foreign providers still refuse each other. + *
+ * + * @author TheMeinerLP + * @version 1.1.0 + * @since 1.0.0 + */ +final class ServiceResolution { + + private ServiceResolution() { + } + + /** + * Finds the single provider of the given service on the classpath. + * + * @param service the service interface + * @param+ * This is not the "no silent choice between two providers" rule weakening: a module's own + * default is a known quantity, registered by the module itself, not a second undocumented + * opinion on the classpath. A caller who registers one foreign provider has stated an + * unambiguous intent, and that intent should not be refused just because the module also ships + * its own fallback. Two foreign providers are a different situation — neither of them + * is the known default, so the classpath genuinely holds two competing, undocumented opinions, + * and this still refuses to guess between them exactly as {@link #discover(Class)} does. + *
+ * + * @param service the service interface + * @param shippedDefault the class of the module's own default implementation, which yields to + * any other registered provider, or null if the service has no such + * default + * @param+ * Returning a name substitutes it; throwing {@link AnvilChunkException} fails the chunk. + * Substituting is right for a server that wants a world to stay loadable and wrong for a tool that + * converts one, which is why this is a policy and not a constant. + *
+ *+ * A policy names a replacement; it does not resolve one. It returns a palette name such as + * {@code "minecraft:stone"}, not an id — the resolver that consults it owns the registry lookup + * that turns a name into an id, the same registry it already needed to look the original, unknown + * name up in the first place. That split keeps this interface, and any implementation of it, free of + * a dependency on Minestom or any registry: naming {@code "minecraft:air"} takes no more than a + * string literal. + *
+ *+ * A policy only decides. It does not count and it does not log: the resolver which consults it keeps + * reporting the name to its {@link AnvilDiagnostics} and writing the log line regardless of what the + * policy does with the entry, so a substituting run stays as visible as a refusing one. Nor does a + * policy get asked twice: if the name it returns is itself unknown, the resolver fails the chunk + * instead of consulting the policy again, which would risk a loop. + *
+ *+ * Called from several threads at once. The policy is resolved once, when the loader is built, + * and both {@link BlockPaletteResolver} and {@link BiomePaletteResolver} keep consulting that same + * instance for as long as the loader lives — including every parallel load, since + * {@link FalcoAnvilLoader#supportsParallelLoading()} reports {@code true}. An implementation + * therefore has to be thread-safe on its own; neither resolver takes a lock around the call. + * {@link DefaultUnknownEntryPolicy}, the shipped default, holds no state and needs none. + *
+ * + * @author TheMeinerLP + * @version 1.1.0 + * @since 2.1.0 + */ +@ApiStatus.Experimental +public interface UnknownEntryPolicy { + + /** + * Decides what an unknown block becomes. + * + * @param name the block name stored in the palette + * @param properties the stored properties, or null if the entry carries none + * @return the name of the block to use instead + * @throws AnvilChunkException if the chunk should fail rather than carry a substitute + */ + String onUnknownBlock(String name, @Nullable CompoundBinaryTag properties); + + /** + * Decides what an unknown biome becomes. + * + * @param name the biome name stored in the palette + * @return the name of the biome to use instead + * @throws AnvilChunkException if the chunk should fail rather than carry a substitute + */ + String onUnknownBiome(String name); +} diff --git a/falco-anvil/src/main/resources/META-INF/services/net.onelitefeather.falco.anvil.ChunkVersionPolicy b/falco-anvil/src/main/resources/META-INF/services/net.onelitefeather.falco.anvil.ChunkVersionPolicy new file mode 100644 index 0000000..67b2e89 --- /dev/null +++ b/falco-anvil/src/main/resources/META-INF/services/net.onelitefeather.falco.anvil.ChunkVersionPolicy @@ -0,0 +1 @@ +net.onelitefeather.falco.anvil.DefaultChunkVersionPolicy diff --git a/falco-anvil/src/main/resources/META-INF/services/net.onelitefeather.falco.anvil.UnknownEntryPolicy b/falco-anvil/src/main/resources/META-INF/services/net.onelitefeather.falco.anvil.UnknownEntryPolicy new file mode 100644 index 0000000..1d14ce4 --- /dev/null +++ b/falco-anvil/src/main/resources/META-INF/services/net.onelitefeather.falco.anvil.UnknownEntryPolicy @@ -0,0 +1 @@ +net.onelitefeather.falco.anvil.DefaultUnknownEntryPolicy diff --git a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/ChunkVersionPolicyTest.java b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/ChunkVersionPolicyTest.java new file mode 100644 index 0000000..588710a --- /dev/null +++ b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/ChunkVersionPolicyTest.java @@ -0,0 +1,56 @@ +package net.onelitefeather.falco.anvil; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.ListBinaryTag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@link DefaultChunkVersionPolicy} directly, against chunk data built by hand rather than + * through the loader. What used to be the loader's private {@code requireReadableVersion} guard now + * lives here, unchanged in its decision logic and reachable without a running Minestom environment. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.1.0 + */ +class ChunkVersionPolicyTest { + + @Test + void testTheDefaultPolicyRefusesALevelLayout() { + CompoundBinaryTag legacy = CompoundBinaryTag.builder() + .put("Level", CompoundBinaryTag.builder().put("Sections", ListBinaryTag.empty()).build()) + .build(); + + ChunkDataException failure = assertThrows(ChunkDataException.class, + () -> new DefaultChunkVersionPolicy().check(legacy, 2844)); + assertEquals(ChunkDataException.Reason.UNSUPPORTED_CHUNK_VERSION, failure.reason()); + } + + @Test + void testTheDefaultPolicyAcceptsAChunkWithoutAStoredVersion() throws Exception { + CompoundBinaryTag toolWritten = CompoundBinaryTag.builder() + .put("sections", ListBinaryTag.empty()) + .build(); + + new DefaultChunkVersionPolicy().check(toolWritten, 2844); + } + + @Test + void testTheDefaultPolicyRefusesAMistypedVersion() { + CompoundBinaryTag broken = CompoundBinaryTag.builder() + .putString("DataVersion", "not-a-number") + .put("sections", ListBinaryTag.empty()) + .build(); + + ChunkDataException failure = assertThrows(ChunkDataException.class, + () -> new DefaultChunkVersionPolicy().check(broken, 2844)); + // Pins the distinction the message is for, not its exact wording: a mistyped DataVersion is + // not the same failure as a DataVersion that is simply too old, and the loader's log line now + // relays this message instead of recomputing that distinction itself. + assertTrue(failure.getMessage().contains("number"), failure.getMessage()); + } +} diff --git a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderBuilderTest.java b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderBuilderTest.java index e18eca6..fd56a70 100644 --- a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderBuilderTest.java +++ b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderBuilderTest.java @@ -20,10 +20,12 @@ import java.util.concurrent.CopyOnWriteArrayList; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Pins down the builder of the loader. @@ -319,6 +321,120 @@ void testTheMinimumDataVersionSurvivesEveryOtherSetter(@TempDir Path worldRoot) } } + @Test + void testAnExplicitVersionPolicySurvivesEveryOtherSetter(@TempDir Path worldRoot) throws Exception { + ChunkVersionPolicy policy = (data, minimum) -> { + }; + + try (FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .versionPolicy(policy) + .openRegionLimit(4) + .compressionLevel(3) + .saveParallelism(2) + .build(worldRoot, OVERWORLD)) { + + assertSame(policy, loader.versionPolicy()); + } + } + + @Test + void testDiscoverVersionPolicySurvivesEveryOtherSetterAfterClearingAnExplicitOne(@TempDir Path worldRoot) throws Exception { + try (FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .versionPolicy((data, minimum) -> { + }) + .discoverVersionPolicy() + .openRegionLimit(4) + .compressionLevel(3) + .saveParallelism(2) + .build(worldRoot, OVERWORLD)) { + + assertInstanceOf(DefaultChunkVersionPolicy.class, loader.versionPolicy()); + } + } + + @Test + void testAnExplicitUnknownEntryPolicySurvivesEveryOtherSetter(@TempDir Path worldRoot) throws Exception { + UnknownEntryPolicy policy = new DefaultUnknownEntryPolicy(); + + try (FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .unknownEntryPolicy(policy) + .openRegionLimit(4) + .compressionLevel(3) + .saveParallelism(2) + .build(worldRoot, OVERWORLD)) { + + assertSame(policy, loader.unknownEntryPolicy()); + } + } + + @Test + void testDiscoverUnknownEntryPolicySurvivesEveryOtherSetterAfterClearingAnExplicitOne(@TempDir Path worldRoot) throws Exception { + try (FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .unknownEntryPolicy(new DefaultUnknownEntryPolicy()) + .discoverUnknownEntryPolicy() + .openRegionLimit(4) + .compressionLevel(3) + .saveParallelism(2) + .build(worldRoot, OVERWORLD)) { + + assertInstanceOf(DefaultUnknownEntryPolicy.class, loader.unknownEntryPolicy()); + } + } + + /** + * The combination this class exists to refuse: an explicit {@link UnknownEntryPolicy} and a + * custom resolver together. A resolver supplied through {@link Builder#blockResolver} is used + * exactly as given, so the configured policy would never actually be consulted while + * {@link FalcoAnvilLoader#unknownEntryPolicy()} kept reporting it as active — the exact trap a + * caller who names both slots would otherwise fall into silently. + */ + @Test + void testCombiningAnExplicitUnknownEntryPolicyWithACustomBlockResolverIsRefused() { + FalcoAnvilLoader.Builder builder = FalcoAnvilLoader.builder() + .unknownEntryPolicy(new DefaultUnknownEntryPolicy()) + .blockResolver(new RefusingResolver()); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> builder.build(this.worldRoot, OVERWORLD)); + assertTrue(failure.getMessage().contains("UnknownEntryPolicy"), failure.getMessage()); + assertTrue(failure.getMessage().contains("blockResolver"), failure.getMessage()); + } + + /** + * The mirror of the block resolver case, and the other slot the guard has to consider: an + * explicit request for classpath discovery combined with a custom biome resolver is refused for + * the same reason a resolved instance is — a resolver supplied through + * {@link Builder#biomeResolver} never sees whatever discovery would have found. + */ + @Test + void testCombiningDiscoverUnknownEntryPolicyWithACustomBiomeResolverIsRefused() { + FalcoAnvilLoader.Builder builder = FalcoAnvilLoader.builder() + .discoverUnknownEntryPolicy() + .biomeResolver(new RefusingResolver()); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> builder.build(this.worldRoot, OVERWORLD)); + assertTrue(failure.getMessage().contains("UnknownEntryPolicy"), failure.getMessage()); + assertTrue(failure.getMessage().contains("biomeResolver"), failure.getMessage()); + } + + /** + * The existing case the guard must not break: a custom resolver on its own, without either + * {@link Builder#unknownEntryPolicy} or {@link Builder#discoverUnknownEntryPolicy} ever being + * called, still builds. {@link #testAGivenBlockResolverIsTheOneTheLoaderDecodesWith(Env)} already + * exercises this shape end to end; this test pins the construction itself so the guard's + * "configured" flag, not merely a null check, is what the refusal above keys on. + */ + @Test + void testACustomBlockResolverWithoutAnyPolicyConfigurationStillBuilds() throws Exception { + try (FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .blockResolver(new RefusingResolver()) + .build(this.worldRoot, OVERWORLD)) { + + assertNotNull(loader); + } + } + @Test void testTheBuilderCanBeReusedAfterASlotChanged() throws Exception { FalcoAnvilLoader.Builder builder = FalcoAnvilLoader.builder().openRegionLimit(8); diff --git a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderIntegrationTest.java b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderIntegrationTest.java index 6a3d097..cfeeb29 100644 --- a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderIntegrationTest.java +++ b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/FalcoAnvilLoaderIntegrationTest.java @@ -784,6 +784,53 @@ void testASectionsKeyStoredAsTheWrongTypeWithLevelIsRefused(Env env) throws Exce } } + @Test + void testAPolicyThatAllowsEverythingLetsALegacyChunkThrough(Env env) throws Exception { + CompoundBinaryTag legacy = CompoundBinaryTag.builder() + .put("Level", CompoundBinaryTag.builder().put("Sections", ListBinaryTag.empty()).build()) + .putString("Status", "minecraft:full") + .build(); + writeRawChunk(11, 11, legacy); + + try (FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .versionPolicy((data, minimum) -> { }) + .build(this.worldRoot, OVERWORLD)) { + Instance instance = env.createEmptyInstance(loader); + + assertNotNull(loader.loadChunk(instance, 11, 11)); + } + } + + /** + * Documents the cost of the loader's default: without any {@link ChunkVersionPolicy}, nothing + * checks a chunk's data version at all, so a pre-{@code 21w43a} chunk which stores its data + * under {@code Level} is not refused. It loads exactly as it did before this loader ever gained + * a version guard — as a chunk of air, because {@code sections} is absent from the root. This is + * a deliberate choice of the project, not a regression: a caller has to opt out of the check + * explicitly, with {@code versionPolicy(null)}, to reach this behaviour. + */ + @Test + void testWithoutAnyPolicyALegacyChunkIsNotChecked(Env env) throws Exception { + CompoundBinaryTag legacy = CompoundBinaryTag.builder() + .put("Level", CompoundBinaryTag.builder().put("Sections", ListBinaryTag.empty()).build()) + .putString("Status", "minecraft:full") + .build(); + writeRawChunk(12, 12, legacy); + + try (FalcoAnvilLoader loader = FalcoAnvilLoader.builder() + .versionPolicy(null) + .build(this.worldRoot, OVERWORLD)) { + Instance instance = env.createEmptyInstance(loader); + Chunk chunk = loader.loadChunk(instance, 12, 12); + + assertNotNull(chunk); + // The claim of this test's own javadoc: not merely that a chunk comes back, but that it + // is the chunk of air the legacy Level layout decodes to when sections is absent and no + // policy caught it. + assertEquals(Block.AIR, blockAt(chunk, 0, 40, 0)); + } + } + @Test void testUnloadingAForeignChunkIsIgnored(Env env) throws Exception { Instance instance = env.createEmptyInstance(loader()); diff --git a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/ServiceResolutionTest.java b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/ServiceResolutionTest.java new file mode 100644 index 0000000..8b1784f --- /dev/null +++ b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/ServiceResolutionTest.java @@ -0,0 +1,176 @@ +package net.onelitefeather.falco.anvil; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins down the resolution rules {@link ServiceResolution} offers to the two extension points built + * on top of it: how a service is discovered on the classpath, and how an explicitly configured + * instance relates to that discovery. + *+ * The dummy providers this test resolves are registered through the test resources, under + * {@code META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$Dummy} and two more + * files named after {@link DefaultAware} and {@link DefaultAwareWithTwoForeign} below, so the main + * module registers nothing extra. + *
+ * + * @author TheMeinerLP + * @version 1.1.0 + * @since 1.0.0 + */ +class ServiceResolutionTest { + + interface Dummy { + String name(); + } + + public static final class FirstDummy implements Dummy { + public FirstDummy() { + } + + @Override + public String name() { + return "first"; + } + } + + public static final class SecondDummy implements Dummy { + public SecondDummy() { + } + + @Override + public String name() { + return "second"; + } + } + + interface Absent { + } + + /** + * A service with a "shipped default" registered next to a single foreign provider, for the two + * {@link ServiceResolution#discover(Class, Class)} cases below. + */ + interface DefaultAware { + String name(); + } + + public static final class ShippedDefault implements DefaultAware { + public ShippedDefault() { + } + + @Override + public String name() { + return "shipped"; + } + } + + public static final class ForeignProvider implements DefaultAware { + public ForeignProvider() { + } + + @Override + public String name() { + return "foreign"; + } + } + + /** + * A separate service, registered with a shipped default and two foreign providers, so + * that "the default steps aside" and "two foreign providers still refuse each other" can be + * pinned down independently of one another. + */ + interface DefaultAwareWithTwoForeign { + String name(); + } + + public static final class AnotherShippedDefault implements DefaultAwareWithTwoForeign { + public AnotherShippedDefault() { + } + + @Override + public String name() { + return "shipped"; + } + } + + public static final class FirstForeignProvider implements DefaultAwareWithTwoForeign { + public FirstForeignProvider() { + } + + @Override + public String name() { + return "first-foreign"; + } + } + + public static final class SecondForeignProvider implements DefaultAwareWithTwoForeign { + public SecondForeignProvider() { + } + + @Override + public String name() { + return "second-foreign"; + } + } + + @Test + void testAServiceWithNoProviderResolvesToNothing() { + assertNull(ServiceResolution.discover(Absent.class)); + } + + @Test + void testTwoProvidersAreRefusedAndBothAreNamed() { + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> ServiceResolution.discover(Dummy.class)); + + assertTrue(failure.getMessage().contains("FirstDummy"), failure.getMessage()); + assertTrue(failure.getMessage().contains("SecondDummy"), failure.getMessage()); + } + + @Test + void testAnExplicitInstanceAndDiscoveryTogetherAreRefused() { + Dummy explicit = () -> "explicit"; + + assertThrows(IllegalStateException.class, + () -> ServiceResolution.choose(Dummy.class, explicit, true)); + } + + @Test + void testAnExplicitInstanceIsUsedWithoutTouchingTheClasspath() { + Dummy explicit = () -> "explicit"; + + assertEquals("explicit", ServiceResolution.choose(Dummy.class, explicit, false).name()); + } + + @Test + void testNeitherExplicitNorDiscoveredResolvesToNothing() { + assertNull(ServiceResolution.choose(Dummy.class, null, false)); + } + + @Test + void testAForeignProviderWinsOverTheShippedDefault() { + DefaultAware resolved = ServiceResolution.discover(DefaultAware.class, ShippedDefault.class); + + assertEquals("foreign", resolved.name()); + } + + @Test + void testTwoForeignProvidersAreStillRefusedEvenWithAShippedDefaultRegistered() { + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> ServiceResolution.discover(DefaultAwareWithTwoForeign.class, AnotherShippedDefault.class)); + + assertTrue(failure.getMessage().contains("FirstForeignProvider"), failure.getMessage()); + assertTrue(failure.getMessage().contains("SecondForeignProvider"), failure.getMessage()); + // The other half of the rule the spec states: the shipped default stepped aside because a + // foreign provider was found, so it is not one of the competing opinions the message lists. + // Naming it here too would send the reader chasing a class that was never actually a + // candidate. + assertFalse(failure.getMessage().contains("AnotherShippedDefault"), failure.getMessage()); + } +} diff --git a/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/UnknownEntryPolicyTest.java b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/UnknownEntryPolicyTest.java new file mode 100644 index 0000000..36538a0 --- /dev/null +++ b/falco-anvil/src/test/java/net/onelitefeather/falco/anvil/UnknownEntryPolicyTest.java @@ -0,0 +1,160 @@ +package net.onelitefeather.falco.anvil; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.minestom.server.world.biome.Biome; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@link DefaultUnknownEntryPolicy} directly and pins how {@link BlockPaletteResolver} and + * {@link BiomePaletteResolver} each use whichever {@link UnknownEntryPolicy} they were built with. + *+ * The counting cases are the ones that matter most: counting an unknown entry is the resolver's job, + * not the policy's, and it has to keep happening even when the policy substitutes instead of + * throwing. Losing it would make a substituting run silent again, which is exactly what this whole + * extension point exists to prevent. + *
+ *+ * {@link UnknownEntryPolicy} hands back a name, not an id — the resolver owns the registry lookup + * that turns a name into one, using the same registry it already needed for the original, unknown + * name. That is what the "unusable substitute" cases pin: the resolver has to fail if the name a + * policy substitutes is itself unresolvable, and it must not ask the policy a second time to find + * out, which could loop. The failure surfaces as {@link IllegalStateException} rather than {@link + * AnvilChunkException} directly, because {@code FalcoAnvilLoader} is the only class that constructs + * the latter — it wraps this into one when a chunk is read through the loader. + *
+ *+ * The biome cases use {@link Biome#createDefaultRegistry()} rather than the biome registry of a + * running server, so this class needs no Minestom test environment: {@link BiomePaletteResolver}'s + * package-private three-argument constructor exists for exactly this, to inject both a policy and a + * registry supplier without starting a server. + *
+ * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.1.0 + */ +class UnknownEntryPolicyTest { + + @Test + void testTheDefaultPolicyReplacesAnUnknownBlockWithAir() { + assertEquals("minecraft:air", new DefaultUnknownEntryPolicy().onUnknownBlock("falco:nope", null)); + } + + @Test + void testTheDefaultPolicyReplacesAnUnknownBiomeWithPlains() { + assertEquals("minecraft:plains", new DefaultUnknownEntryPolicy().onUnknownBiome("falco:nope")); + } + + @Test + void testARefusingPolicyFailsTheChunkInsteadOfSubstituting() { + UnknownEntryPolicy refusing = new UnknownEntryPolicy() { + @Override + public String onUnknownBlock(String name, CompoundBinaryTag properties) { + throw new AnvilChunkException("The block " + name + " has no mapping"); + } + + @Override + public String onUnknownBiome(String name) { + throw new AnvilChunkException("The biome " + name + " has no mapping"); + } + }; + + AnvilChunkException failure = assertThrows(AnvilChunkException.class, + () -> new BlockPaletteResolver(new AnvilDiagnostics(), refusing).toId("falco:nope", null)); + assertTrue(failure.getMessage().contains("falco:nope"), failure.getMessage()); + } + + @Test + void testARefusingPolicyFailsTheChunkInsteadOfSubstitutingForABiome() { + UnknownEntryPolicy refusing = new UnknownEntryPolicy() { + @Override + public String onUnknownBlock(String name, CompoundBinaryTag properties) { + throw new AnvilChunkException("The block " + name + " has no mapping"); + } + + @Override + public String onUnknownBiome(String name) { + throw new AnvilChunkException("The biome " + name + " has no mapping"); + } + }; + + AnvilChunkException failure = assertThrows(AnvilChunkException.class, + () -> new BiomePaletteResolver(new AnvilDiagnostics(), refusing, Biome::createDefaultRegistry) + .toId("falco:nope", null)); + assertTrue(failure.getMessage().contains("falco:nope"), failure.getMessage()); + } + + @Test + void testTheResolverStillCountsWhenThePolicySubstitutes() { + AnvilDiagnostics diagnostics = new AnvilDiagnostics(); + + new BlockPaletteResolver(diagnostics, new DefaultUnknownEntryPolicy()).toId("falco:nope", null); + + assertEquals(1, diagnostics.unknownBlockCount()); + } + + @Test + void testTheResolverStillCountsWhenThePolicySubstitutesForABiome() { + AnvilDiagnostics diagnostics = new AnvilDiagnostics(); + + new BiomePaletteResolver(diagnostics, new DefaultUnknownEntryPolicy(), Biome::createDefaultRegistry) + .toId("falco:nope", null); + + assertEquals(1, diagnostics.unknownBiomeCount()); + } + + @Test + void testAnUnusableSubstituteBlockFailsTheChunkAndNamesBothNames() { + AtomicInteger calls = new AtomicInteger(); + UnknownEntryPolicy nonsense = new UnknownEntryPolicy() { + @Override + public String onUnknownBlock(String name, CompoundBinaryTag properties) { + calls.incrementAndGet(); + return "falco:still-nope"; + } + + @Override + public String onUnknownBiome(String name) { + throw new AssertionError("not exercised by this test"); + } + }; + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> new BlockPaletteResolver(new AnvilDiagnostics(), nonsense).toId("falco:nope", null)); + + assertTrue(failure.getMessage().contains("falco:nope"), failure.getMessage()); + assertTrue(failure.getMessage().contains("falco:still-nope"), failure.getMessage()); + assertEquals(1, calls.get(), "the policy is not asked a second time for its own substitute"); + } + + @Test + void testAnUnusableSubstituteBiomeFailsTheChunkAndNamesBothNames() { + AtomicInteger calls = new AtomicInteger(); + UnknownEntryPolicy nonsense = new UnknownEntryPolicy() { + @Override + public String onUnknownBlock(String name, CompoundBinaryTag properties) { + throw new AssertionError("not exercised by this test"); + } + + @Override + public String onUnknownBiome(String name) { + calls.incrementAndGet(); + return "falco:still-nope"; + } + }; + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> new BiomePaletteResolver(new AnvilDiagnostics(), nonsense, Biome::createDefaultRegistry) + .toId("falco:nope", null)); + + assertTrue(failure.getMessage().contains("falco:nope"), failure.getMessage()); + assertTrue(failure.getMessage().contains("falco:still-nope"), failure.getMessage()); + assertEquals(1, calls.get(), "the policy is not asked a second time for its own substitute"); + } +} diff --git a/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$DefaultAware b/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$DefaultAware new file mode 100644 index 0000000..2f4677d --- /dev/null +++ b/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$DefaultAware @@ -0,0 +1,2 @@ +net.onelitefeather.falco.anvil.ServiceResolutionTest$ShippedDefault +net.onelitefeather.falco.anvil.ServiceResolutionTest$ForeignProvider diff --git a/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$DefaultAwareWithTwoForeign b/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$DefaultAwareWithTwoForeign new file mode 100644 index 0000000..e6193dc --- /dev/null +++ b/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$DefaultAwareWithTwoForeign @@ -0,0 +1,3 @@ +net.onelitefeather.falco.anvil.ServiceResolutionTest$AnotherShippedDefault +net.onelitefeather.falco.anvil.ServiceResolutionTest$FirstForeignProvider +net.onelitefeather.falco.anvil.ServiceResolutionTest$SecondForeignProvider diff --git a/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$Dummy b/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$Dummy new file mode 100644 index 0000000..a9c2788 --- /dev/null +++ b/falco-anvil/src/test/resources/META-INF/services/net.onelitefeather.falco.anvil.ServiceResolutionTest$Dummy @@ -0,0 +1,2 @@ +net.onelitefeather.falco.anvil.ServiceResolutionTest$FirstDummy +net.onelitefeather.falco.anvil.ServiceResolutionTest$SecondDummy diff --git a/falco-archunit/src/test/java/net/onelitefeather/falco/architecture/ForeignCouplingTest.java b/falco-archunit/src/test/java/net/onelitefeather/falco/architecture/ForeignCouplingTest.java index e7ca831..9430626 100644 --- a/falco-archunit/src/test/java/net/onelitefeather/falco/architecture/ForeignCouplingTest.java +++ b/falco-archunit/src/test/java/net/onelitefeather/falco/architecture/ForeignCouplingTest.java @@ -54,7 +54,9 @@ class ForeignCouplingTest { private static final String ANVIL_NBT_LAYER = "net\\.onelitefeather\\.falco\\.anvil\\." + "(FalcoAnvilLoader|SectionCodec|NbtReads|PaletteEntryResolver" - + "|BlockPaletteResolver|BiomePaletteResolver)(\\$.*)?"; + + "|BlockPaletteResolver|BiomePaletteResolver" + + "|ChunkVersionPolicy|DefaultChunkVersionPolicy" + + "|UnknownEntryPolicy|DefaultUnknownEntryPolicy)(\\$.*)?"; private static final String ANVIL_FILE_BOUNDARY = "net\\.onelitefeather\\.falco\\.anvil\\.(RegionFile|FalcoAnvilLoader)(\\$.*)?"; @@ -251,9 +253,14 @@ class ForeignCouplingTest { * import down here is not untidy, it is the door through which the loss of predictability under * concurrency comes back. * - *Phrased as a complement rather than a hand-maintained allow list, so it covers - * {@code PaletteData}, {@code AnvilChunkException} and nested types such as - * {@code RegionFile$RawChunk} without maintenance when a class is added. + *
{@code ANVIL_NBT_LAYER} is a hand-maintained list, not a complement: it names the ten + * classes of the NBT layer as of this writing — {@code FalcoAnvilLoader}, + * {@code SectionCodec}, {@code NbtReads}, {@code PaletteEntryResolver}, + * {@code BlockPaletteResolver}, {@code BiomePaletteResolver}, {@code ChunkVersionPolicy}, + * {@code DefaultChunkVersionPolicy}, {@code UnknownEntryPolicy} and + * {@code DefaultUnknownEntryPolicy} — and a class added to that layer later has to be + * added here too, the way the four policy classes were not when they were first written. + * Nested types need no separate entry; they come along through the {@code (\$.*)?} suffix. */ @ArchTest static final ArchRule byteLayerKnowsNoNbt = noClasses()