This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
WorldManager is a Paper-only server plugin (Java 21) for creating, loading, unloading, deleting, backing up, restoring, teleporting between, and pre-generating Minecraft worlds, with a native Dialog-based in-game menu and two optional per-world-group features: shared inventories and linked Nether/End portals. Distributed on Hangar, Modrinth, CurseForge, and SpigotMC (SpigotMC/legacy Spigot builds are no longer supported as of 3.0.0 — the GUI is built on Paper's Dialog API, which has no Spigot/CraftBukkit equivalent).
- Build a shaded plugin jar:
./gradlew shadowJar(or./gradlew build). This usesbuild.gradle, which targets Paper API1.21.7-R0.1-SNAPSHOT(deliberately the oldest version with the Dialog API, not the latest — Paper's Bukkit-facing API is backward-additive, so compiling against the floor gives the widest real compatibility range, advertised as 1.21.7 → 26.2) and, as adoLaststep, copies the built jar intoserver/plugins/— a local Paper server checkout used for manual testing (gitignored). server/is a full local Paper server install (jar, configs, world data,plugins/). Start it manually to test the plugin in-game; there is no scripted launch task.- CI/releases use a separate build file,
alt.gradle(./gradlew -b alt.gradle shadowJar), which outputs to./outinstead of copying intoserver/plugins. Keepbuild.gradleandalt.gradledependency/version blocks in sync when changing dependencies — they are not shared. - No test suite exists in this repository.
- Plugin version lives in
build.gradle(version = '...') and is interpolated intosrc/main/resources/plugin.ymlviaprocessResources(${version}placeholder). Bump it there. - GitHub Actions (
.github/workflows/gradle.yml) builds withalt.gradleon push/PR tomasterand cuts a GitHub release tagged with the extracted version.
Entry point: fr.mathildeuh.worldmanager.WorldManager (JavaPlugin). onEnable() wires everything up in
order: default config → PlaceholderAPI hook → lang file → bStats metrics → Adventure BukkitAudiences → the
worldmanager command → event listeners → backup/worlds config files → database → linked-worlds config →
update checker → GUI cache warm-up. Plugin-wide singletons (langConfig, backupConfig, worldsConfig,
configFile, adventure) are exposed as static fields on WorldManager and referenced directly from
anywhere in the codebase instead of being dependency-injected — follow this pattern for new global state.
Command dispatch: A single worldmanager command (alias wm) is registered in plugin.yml. All routing
happens in commands/WorldManagerCommand.java via a manual switch on args[0] (including short aliases like
c/del/l/u/tp), with a per-subcommand permission check (worldmanager.<name>) before dispatch. Each
subcommand is its own class in commands/subcommands/ (Create, Delete, Load, Unload, Backup,
Restore, Teleport, Lists, Gui), instantiated with the CommandSender and invoked via an execute(...)
method. Tab completion is hand-rolled in the same class, branching on args.length and the subcommand name.
World pre-generation is its own nested sub-area under commands/subcommands/pregenerator/ (Pregen,
ChunkGenerator), since it's stateful/async (start/stop/pause/resume) rather than a one-shot action.
Configuration is split across several YAML files, each with its own manager class under configs/:
config.yml— main plugin config (Bukkit's standardgetConfig()), holdslang,database.*,enable-linked-inventory, andlinked-worlds-inventory.<group>: [worldNames].worlds.yml(WorldsConfig) — persisted metadata per created/loaded world:type,environment,generator,createdBy, and per-worldgameRules; worlds are recreated from this file on every startup.backups.ymlunderbackups/WorldManager/(BackupConfig) — backup registry, separate fromworlds.yml.lang/<locale>.yml(LangConfig) — one file per supported locale (en,fr,es,de,pl,ru); messages use MiniMessage markup with positional placeholders ({0},{1}, ...) substituted byLangConfig.formatMessage. Add new user-facing strings to every lang file, not justen.yml.
Messaging: never build/send Components ad hoc. Look up text via WorldManager.langConfig.sendError/ sendSuccess/sendWaiting(sender, "path.to.key", args...), which resolves the MiniMessage string from the
active lang file, wraps it with a status icon + "World Manager" prefix (messages/MessageUtils.java), and
sends it through Adventure (WorldManager.adventure()), which supports both players and console.
Database layer (database/) backs the linked-worlds inventory feature only (not world data itself).
DatabaseConnection is an abstract base with SQLiteConnection and MySQLConnection implementations;
DatabaseFactory.createConnection() picks one based on config.yml's database.type. DatabaseManager
serializes player inventories (main/armor/offhand + health/food/saturation) to a YAML blob stored as a BLOB
column, keyed by (player_uuid, group_name).
Linked-worlds inventory feature: LinkedWorldsManager loads linked-worlds-inventory groups from
config.yml into in-memory maps (world→group). events/WorldChangeListener reacts to
PlayerChangedWorldEvent/PlayerQuitEvent: if source and destination worlds share a group, the inventory is
left untouched; if neither world is in a configured group, the inventory is also left untouched (this is
load-bearing — a bug here in 2.2.0–2.2.1 wiped inventories on every world change between two ungrouped worlds,
regardless of whether the feature was even enabled; see AUDIT.md); otherwise the outgoing group's inventory
is saved and the incoming group's inventory is restored (or the player is reset to a clean slate) via
configs/PlayerInventoryManager, backed by DatabaseManager. The enable-linked-inventory config check
must default to false everywhere it's read (WorldChangeListener.isFeatureEnabled(),
LinkedWordsManager.loadLinkedWorlds()) — this is an opt-in feature, never fail-open.
Linked portals feature (new in 3.0.0): configs/LinkedPortalsManager loads the linked-portals config
section (overworld name → {nether, end} world names) into bidirectional lookup maps.
events/PortalLinkListener handles PlayerPortalEvent for NETHER_PORTAL/END_PORTAL/END_GATEWAY causes;
it takes vanilla's own already-scaled/clamped getTo() location and just swaps out the target World,
instead of re-implementing nether coordinate scaling or End platform placement itself.
In-game menu (dialogs/) is built entirely on Paper's Dialog API (io.papermc.paper.dialog.Dialog /
io.papermc.paper.registry.data.dialog.*), introduced in Paper 1.21.7 and still marked @ApiStatus.Experimental
upstream. Dialogs are created ad-hoc per open via Dialog.create(factory -> factory.empty().base(...).type(...))
and shown with player.showDialog(dialog) — there is no bootstrap-time registry registration (that path exists
in the API but requires migrating to paper-plugin.yml, which this plugin deliberately does not need). Button
actions use inline DialogAction.customClick(DialogActionCallback, ClickCallback.Options) callbacks rather than
a central PlayerCustomClickEvent listener — each button owns its own handler, no Key-based dispatch table.
One class per screen (MainMenuDialog, CreatorDialog, EditorListDialog, WorldOptionsDialog,
GameRuleListDialog, GameRuleEditDialog, LoaderDialog), each with a static build(...) factory method that
reconstructs the screen from live config/world state — same "always rebuild, never mutate a cached instance"
convention the old chest-GUI system used. DialogUtils centralizes MiniMessage/lang-key text building and
button/callback construction, mirroring messages/MessageUtils's role for chat messages. Manual pagination
(world lists, game rule lists) works by having Prev/Next buttons call back into the same screen's build(...)
with page±1 and re-show the result — there's no native Dialog pagination widget.
World creation/loading goes through Bukkit's WorldCreator (type/environment/seed/generator), then is
persisted into worlds.yml via WorldManager.addWorld/removeWorld (delegating to WorldsConfig) so worlds
survive restarts. worlds/EmptyWorldGenerator provides a void/empty chunk generator option. Any world name
that comes from user input (chat, dialog form fields, command args) must be checked with
util/WorldNameValidator.isValid(name) before being used to build a File/WorldCreator — this closes a
path-traversal gap fixed in 3.0.0. util/WorldFolders centralizes the "scan the world container for folders
containing level.dat" logic (previously duplicated three times).
- Static-singleton access to plugin-wide config/state via
WorldManager.*(includingWorldManager.getInstance()for the plugin instance itself) — don't introduce a DI framework. - All player/console-facing text goes through
LangConfig+MessageUtils/DialogUtils, sourced from the lang YAMLs, never hardcoded strings — this keeps the 6 shipped translations complete. Add new keys to all sixlang/*.ymlfiles in the same pass that introduces them, not justen.yml. - Permission checks (
worldmanager.<subcommand>) happen inWorldManagerCommandbefore a subcommand class is even constructed, not inside the subcommand itself. - Heavy I/O (zip pack/unpack, world-folder deletion, HTTP calls) must run via
Bukkit.getScheduler().runTaskAsynchronously(...), hopping back to the main thread only for the specific Bukkit calls that require it (Bukkit.createWorld,Bukkit.unloadWorld, sending messages). Several such main-thread-blocking bugs were fixed in 3.0.0 — don't reintroduce the pattern. - Package layout:
commands/subcommandsfor command logic,configsfor YAML-backed managers,dialogsfor the Dialog-based menu,databasefor the inventory persistence layer,messagesfor chat output formatting,eventsfor Bukkit listeners,utilfor stateless helpers (WorldNameValidator,WorldFolders,UpdateChecker).