Skip to content

feat: serve the lobby world through Falco (Stage 1) - #215

Merged
TheMeinerLP merged 11 commits into
docs/lobby-season-specfrom
feat/falco-engine
Aug 28, 2026
Merged

feat: serve the lobby world through Falco (Stage 1)#215
TheMeinerLP merged 11 commits into
docs/lobby-season-specfrom
feat/falco-engine

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Stacked on #214. Implements spec Stage 1 — US-1.01 … US-1.06.

What changed

Engine. MapProvider now loads through FalcoAnvilLoader instead of Minestom's AnvilLoader, and lights chunks through falco-light's ChunkLightService instead of LightingChunk.relight(...). The loader is created once per world root rather than per saveMap call — it holds region files open — so MapProvider became AutoCloseable and Titan.terminate() closes it.

The bug this stage exists to fix. MapPool.peekMap() used to ignore TITAN_LOBBY_MAP whenever exactly one world existed, and threw a bare NoSuchElementException otherwise. So a typo was invisible locally and killed startup in production, without naming the world it looked for. Now the property is always honoured; a missing world logs a warning naming both what was searched for and what was found, then falls back to world. Only an empty worlds/ is still fatal.

Tests — 70 green

MapPoolTest (11 cases) covers the regression directly: missing named world with several worlds present. Plus MapProviderIntegrationTest (3 Cyano cases) asserting the loader type, the lighting chunk supplier, and that close() really closes the loader.

Three things reviewers should know

1. Falco is 2.1.0, not the 0.3.0 the spec named. 0.3.0 predates Minestom 26.1. Side effect: Falco brings mycelium-bom 1.7.2 where aonyx-bom 0.8.0 brings 1.7.1, so Minestom moves 2026.06.05-26.1.22026.06.20-26.1.2. Same protocol, but it is an unrequested bump — flagging it rather than burying it.

2. falco-light 2.1.0 has a packaging defect. FalcoLightingChunk extends net.onelitefeather.falco.instance.FalcoChunk, but the artifact neither bundles falco-instance nor declares a dependency on it, so ChunkLightScheduler.supplier() does not link. This PR therefore takes the explicit ChunkLightService route. Worth a fix in Falco — its main already changed the superclass to DynamicChunk, so this looks fixed but unreleased.

3. Minestom still owns sky light. calculateWithNeighbours writes block light only; LightingChunk stays the chunk type so light is sent and the sky pass still runs. "Falco instead of Minestom's light engine" is therefore only half true today. calculateSky(chunk) exists but is per-chunk with no neighbour exchange and would seam at chunk borders — judged a regression against Minestom's neighbour-aware pass and left alone.

Known gaps

  • US-1.02 has no direct test — it is Falco's behaviour, and testing it needs a deliberately corrupted region fixture. Covered indirectly by the closed-loader assertion.
  • InstanceChunkLoadEvent is dispatched after the chunk future completes, so a player may already hold the chunk when the listener runs. Pre-existing; mitigated with invalidate() + invalidateResendDelay(). The scheduler route would remove it once point 2 is fixed.
  • :setup does not close the provider — it has no shutdown hook at all. Left alone.
  • Pre-existing landmine untouched: MapPool.clear() nulls referenceList, after which getAvailableMaps() NPEs.

Declares net.onelitefeather:falco-anvil and :falco-light in the inline
version catalog and adds the public OneLiteFeather release repository they
are published to. The :common module consumes both, since the map handling
that will use them lives there.

The lobby spec names 0.3.0. That version predates Minestom 26.1, so it is
not the one that resolves here: 2.1.0 is the current release and the one
whose mycelium BOM lines up with the Minestom version the aonyx BOM pins.
Pulling it in moves Minestom from 2026.06.05-26.1.2 to 2026.06.20-26.1.2,
because falco brings mycelium-bom 1.7.2 where aonyx-bom 0.8.0 brings 1.7.1.
Adds the package-info the OLF Minestom standard asks for (OLF-L3-01) and
removes the hand-placed @NotNull annotations it makes redundant, starting
with MapEntry, which also gains the type and method javadoc of OLF-L4-01.

The remaining classes of the package follow in the commits that change them.
…1.03)

Replaces Minestom's AnvilLoader with Falco's FalcoAnvilLoader and computes
the block light of every loaded chunk with falco-light's ChunkLightService
instead of LightingChunk.relight.

Why the loader matters beyond it being ours: Minestom's reports a chunk it
cannot read as absent, which makes the server generate a fresh one and
overwrite the built world on the next save. Falco's reports the failure
(US-1.02).

The loader is handed the world root rather than its region directory - it
resolves dimensions/<namespace>/<value>/region itself and falls back to a
plain region/ for a world in the older layout, which is the layout the lobby
worlds are in. It also holds region files open for as long as it lives, so
it is created once per world root rather than on every read of the map data,
and MapProvider became AutoCloseable so the app shutdown can close it.

Lighting keeps LightingChunk as the chunk supplier. Falco writes its result
through Light#set, which clears the update flag of the section, so Minestom
does not recompute the block light - it stays responsible for sending the
light and for the sky pass. The cached packets are dropped and a resend is
scheduled after the calculation, because the load event is dispatched after
the loading future completes and the chunk may already be on its way to a
player by then.

Not used here: ChunkLightScheduler with its own chunk supplier, which would
be the fuller replacement. In falco-light 2.1.0 its FalcoLightingChunk
extends FalcoChunk from falco-instance, a module the artefact neither
bundles nor declares, so that route does not link.
Two defects in one method. peekMap took the only entry without ever reading
the property when exactly one world was present, so a machine with one world
behaved differently from production (US-1.05). With more than one world it
threw a bare NoSuchElementException when the named world was absent, which
turned a typo into a lobby that does not start and a stack trace that names
neither the world that was searched for nor the ones that are there
(US-1.04).

The property is now read regardless of how many worlds exist. A named world
that is not present is reported with both halves that make the typo obvious -
the searched name and the found names - and the pool falls back to the
default world. Only a pool without any world at all is still fatal.

The property is read per instance rather than into a static field, which is
what makes any of this testable at all, and the fallback is observable
through isRequestedMapSelected() rather than only in the log.
Adds lobby-world-selection.md, which describes what US-1.06 already required
and the code already did: one directory per world below worlds/, named by
TITAN_LOBBY_MAP, with the region files resolved inside it. Nothing changed
for that story - it is verified and written down.

The same document carries the parts of stage 1 that did change: the fallback
when the named world is absent, and which engine serves the chunks.
exploration-lighting.md is brought level with the lighting path that is
actually in the code now, and the stage 1 rows of the spec are marked
umgesetzt with a note on the three points where the result deviates from what
the spec assumed.
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Test results

 72 files   72 suites   44s ⏱️
 77 tests  75 ✅ 2 💤 0 ❌
234 runs  228 ✅ 6 💤 0 ❌

Results for commit f80ede1.

♻️ This comment has been updated with latest results.

falco-light 2.1.0 declares no dependency on falco-instance, and its
ChunkLightScheduler cannot be loaded without one: the class carries the lambda
body of supplier(), which returns a FalcoLightingChunk, and the verifier
resolves that type and its FalcoChunk supertype while linking the scheduler
rather than when the lambda runs. Probed with the published artifacts on a bare
classpath:

    FAIL net.onelitefeather.falco.light.ChunkLightScheduler
      -> java.lang.NoClassDefFoundError: net/onelitefeather/falco/instance/FalcoChunk

Adding falco-instance of the same release makes the class load. Nothing in
Titan names a type from it, so it is a runtime dependency only.
Three defects, one cause: the light of a chunk was computed once, inline, from
whatever neighbourhood happened to be loaded, and written through Light#set,
which clears the update flag of the section. Nothing ever looks at such a
section again.

- A chunk that loaded before its neighbours was lit without them and was never
  corrected when they arrived, leaving a permanent dark strip along the border
  - the very thing the class comment claimed to prevent.
- FalcoAnvilLoader#supportsParallelLoading() is true, so the load event runs on
  a virtual thread and adjacent chunks light each other's neighbourhoods
  concurrently. Falco's own javadoc forbids that: the result is a seam, never an
  error, and permanent.
- Nothing computed sky light at all. A fresh Light reports itself as valid, so
  LightingChunk skips the sky pass, and a section whose region file carries no
  SkyLight array stayed at level zero for good.

ChunkLightScheduler answers all three. A loaded chunk is only marked, together
with the eight around it, so a chunk is lit again when its neighbours turn up;
the scheduler groups marks into areas that do not overlap and discards a result
whose chunk changed underneath it; and it runs the sky pass itself.
LightingChunk stays the chunk type, because sending the light is what it is for
- the resend timer is armed from the completion callback.

The tests load chunks out of a real region file, which is the only path on which
these defects exist: a generated chunk goes through Chunk#onGenerate(), which
invalidates its sections, and hides all three.
close() nulled the loader root but left the closed loader wired into the
instance and the listeners on its event node. Two things fell out of that: a
player who moved while the server was stopping hit the IllegalStateException of
a loader that had been closed underneath the instance, and saveMap() failed the
same-root check, built a fresh loader and reopened exactly the region files the
shutdown had just closed. The provider now carries a closed flag, unwires
listeners and loader before closing, hands the instance the no-op loader and
refuses to save afterwards. A failure inside the constructor closes the loader
it had already installed rather than leaking it.

:setup creates a MapProvider and never closed it, which is backwards: it is the
module that mutates worlds, and FalcoAnvilLoader#close() is what flushes the
region handles. It now registers a shutdown task like :app does.
describeMissingMap announced the default world unconditionally, including in the
case where the default world is missing as well and peekMap goes on with the
first world it found. The message now names the world that was actually taken
and says why, and the second warning that used to follow it is gone with it.

clear() also set the entry list to null under an @NotNullByDefault package,
which turned every later read of the pool into a NullPointerException instead of
an empty list. The list is emptied instead, and it is copied on the way in
because one of the two filters hands back an immutable list.
exploration-lighting.md said LightingChunk still owned the sky pass. It did not:
a fresh Light reports itself as valid, so the sky pass never ran and sky light
came entirely from the region file, with no fallback and no recomputation. The
section now describes what actually happens - marks on load, one scheduled pass
per tick over non-overlapping areas, block and sky light from Falco, sending
from LightingChunk - and why each half of that is load bearing.

lobby-world-selection.md claimed the loader is closed on shutdown, which was
true for :app only, and described a second warning for the case where the
default world is missing too; there is one warning now and it names the world
that was really taken.

The stage 1 note of the spec recorded the scheduler as unusable. The reason it
gave was right in its facts and too wide in its conclusion, and the note now
carries what was measured instead.
Every falco artifact carries mycelium-bom 1.7.2 as a platform dependency where
aonyx-bom 0.8.0 brings 1.7.1, so highest-wins moved Minestom from
2026.06.05-26.1.2 to 2026.06.20-26.1.2 with nothing announcing it. This
particular bump was harmless, but nothing constrained it and the next falco
release would repeat it unnoticed - which is what NFR-001 forbids.

The three falco dependencies of :common now exclude that BOM. Verified by
resolving rather than assuming: :common, :app and :setup resolve
net.minestom:minestom:2026.06.05-26.1.2 on compile, runtime and test runtime,
and :bridge compiles against the same version, so the divergence where :bridge
was built against one Minestom while :app ran another is gone with it. All 47
:common and 31 :app tests pass on that version, and both fat jars still carry
the falco classes.

The catalog comment claimed 2.1.0 'resolves against the Minestom version the
aonyx BOM pins'. It did not resolve against it, it overrode it.
@TheMeinerLP

Copy link
Copy Markdown
Contributor Author

All six review findings fixed — 78 tests green

I was wrong about the scheduler, and the agent proved it

I told the implementing agent that the ChunkLightScheduler blocker was "over-broad" because FalcoLightingChunk is only named inside a lambda body, so it would never be resolved unless the lambda ran. That is not how the JVM works here. The type is resolved at link time: the verifier must prove FalcoLightingChunk is assignable to the Chunk return type of lambda$supplier$0, which loads it and its FalcoChunk supertype.

Probed against the published 2.1.0 artifacts on a bare classpath:

OK   net.onelitefeather.falco.light.ChunkLightService
OK   net.onelitefeather.falco.light.ChunkLightArea
FAIL net.onelitefeather.falco.light.ChunkLightScheduler
       -> NoClassDefFoundError: net/onelitefeather/falco/instance/FalcoChunk

new ChunkLightScheduler(service) dies before markDirty is ever reached. But the original "not usable" conclusion was also wrong: falco-instance:2.1.0 is published. Declaring it (runtime-only — nothing in Titan names a type from it) makes every probe pass, so the scheduler is now used and no fallback was needed.

This is a genuine falco-light packaging defect worth reporting upstream: the artifact does not declare a dependency it cannot be loaded without.

The old test was blind by construction

testTheLobbyChunksCarryLight is deleted. It asserted the chunk supplier returns a LightingChunk and never loaded a chunk or read a light level — all four defects passed it.

The replacement builds a world on disk (generates chunks in a throwaway instance, writes them through a FalcoAnvilLoader, closes it) and reads light back out of chunks the provider loaded from that region file. That path is the point: a generated chunk goes through Chunk#onGenerate(), which invalidates every section — any test that generates its world cannot see these defects.

Per defect

Status Evidence
D1 light computed once from an incomplete neighbourhood fixed Red on pre-fix code: expected: <14> but was: <0>
D2 concurrent overlapping neighbourhoods fixed Four adjacent chunks, four virtual threads. Red before (inside 0 / across 14). Being a race test it passed by luck sometimes on the old code — D1/D3 are the deterministic ones
D3 sky light never computed fixed The test first proves its own premise (reads the region back with a bare loader, asserts sky level 0), then asserts 15 above the floor and 0 beneath
D4 :setup never closed the provider fixed, untested :setup has no test infrastructure (junit only, no cyano) — verified by reading
D5 close() left the loader wired in fixed closed flag, listeners removed, instance handed ChunkLoader.noop(), saveMap/loadMapData guarded. The constructor-leak path is not tested — no way found to force a failure at that point without a test-only seam
D6 wrong fallback message, clear() nulled the list fixed Message now names the world actually selected. clear() empties instead of nulling — and the list is copied on the way in, because one of the two filters returns an immutable list, so the old clear() would have thrown there

NFR-001 fixed and measured

mycelium-bom is excluded from all three falco dependencies. Verified by resolving, not by assuming: :common, :app and :setup all resolve net.minestom:minestom:2026.06.05-26.1.2 on compile, runtime and testRuntime, and :bridge compiles against the same — so the :bridge/:app divergence closes with it. Excluding the BOM did not break Falco, and both fat jars still carry falco/{anvil,light,instance}.

The catalog comment claiming 2.1.0 "resolves against the Minestom version the aonyx BOM pins" is corrected: it did not resolve against it, it overrode it.

Design note

LightingChunk stays the chunk type — scheduler.supplier() would have been a far larger change and the docs commit to LightingChunk for sending. The load listener only calls markChanged; an InstanceTickEvent listener drives onTick. Since that event carries a duration rather than a timestamp, the pass counter is a local AtomicLong, and because LightingChunk is not LightUpdateAware, the resend timer is armed from the scheduler's onAreaCompleted callback.

@TheMeinerLP
TheMeinerLP merged commit 91080fc into docs/lobby-season-spec Aug 28, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant